1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-16 13:06:24 +02:00

feat(web): manual face tagging and deletion ()

This commit is contained in:
Alex 2025-02-21 09:58:25 -06:00 committed by GitHub
parent 94c0e8253a
commit 007eaaceb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2054 additions and 106 deletions

View file

@ -1,5 +1,9 @@
{
"delete_face": "Delete face",
"tag_people": "Tag People",
"error_delete_face": "Error deleting face from asset",
"search_by_description_example": "Hiking day in Sapa",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
"search_by_description": "Search by description",
"about": "About",
"account": "Account",

View file

@ -118,6 +118,8 @@ Class | Method | HTTP request | Description
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces |
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} |
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix |
@ -278,6 +280,8 @@ Class | Method | HTTP request | Description
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
- [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md)
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
- [AssetFaceCreateDto](doc//AssetFaceCreateDto.md)
- [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md)
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)

View file

@ -87,6 +87,8 @@ part 'model/asset_bulk_upload_check_response_dto.dart';
part 'model/asset_bulk_upload_check_result.dart';
part 'model/asset_delta_sync_dto.dart';
part 'model/asset_delta_sync_response_dto.dart';
part 'model/asset_face_create_dto.dart';
part 'model/asset_face_delete_dto.dart';
part 'model/asset_face_response_dto.dart';
part 'model/asset_face_update_dto.dart';
part 'model/asset_face_update_item.dart';

View file

@ -16,6 +16,89 @@ class FacesApi {
final ApiClient apiClient;
/// Performs an HTTP 'POST /faces' operation and returns the [Response].
/// Parameters:
///
/// * [AssetFaceCreateDto] assetFaceCreateDto (required):
Future<Response> createFaceWithHttpInfo(AssetFaceCreateDto assetFaceCreateDto,) async {
// ignore: prefer_const_declarations
final path = r'/faces';
// ignore: prefer_final_locals
Object? postBody = assetFaceCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [AssetFaceCreateDto] assetFaceCreateDto (required):
Future<void> createFace(AssetFaceCreateDto assetFaceCreateDto,) async {
final response = await createFaceWithHttpInfo(assetFaceCreateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'DELETE /faces/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetFaceDeleteDto] assetFaceDeleteDto (required):
Future<Response> deleteFaceWithHttpInfo(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async {
// ignore: prefer_const_declarations
final path = r'/faces/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = assetFaceDeleteDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetFaceDeleteDto] assetFaceDeleteDto (required):
Future<void> deleteFace(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async {
final response = await deleteFaceWithHttpInfo(id, assetFaceDeleteDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /faces' operation and returns the [Response].
/// Parameters:
///

View file

@ -230,6 +230,10 @@ class ApiClient {
return AssetDeltaSyncDto.fromJson(value);
case 'AssetDeltaSyncResponseDto':
return AssetDeltaSyncResponseDto.fromJson(value);
case 'AssetFaceCreateDto':
return AssetFaceCreateDto.fromJson(value);
case 'AssetFaceDeleteDto':
return AssetFaceDeleteDto.fromJson(value);
case 'AssetFaceResponseDto':
return AssetFaceResponseDto.fromJson(value);
case 'AssetFaceUpdateDto':

View file

@ -0,0 +1,155 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFaceCreateDto {
/// Returns a new [AssetFaceCreateDto] instance.
AssetFaceCreateDto({
required this.assetId,
required this.height,
required this.imageHeight,
required this.imageWidth,
required this.personId,
required this.width,
required this.x,
required this.y,
});
String assetId;
int height;
int imageHeight;
int imageWidth;
String personId;
int width;
int x;
int y;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFaceCreateDto &&
other.assetId == assetId &&
other.height == height &&
other.imageHeight == imageHeight &&
other.imageWidth == imageWidth &&
other.personId == personId &&
other.width == width &&
other.x == x &&
other.y == y;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(height.hashCode) +
(imageHeight.hashCode) +
(imageWidth.hashCode) +
(personId.hashCode) +
(width.hashCode) +
(x.hashCode) +
(y.hashCode);
@override
String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'height'] = this.height;
json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth;
json[r'personId'] = this.personId;
json[r'width'] = this.width;
json[r'x'] = this.x;
json[r'y'] = this.y;
return json;
}
/// Returns a new [AssetFaceCreateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceCreateDto? fromJson(dynamic value) {
upgradeDto(value, "AssetFaceCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFaceCreateDto(
assetId: mapValueOfType<String>(json, r'assetId')!,
height: mapValueOfType<int>(json, r'height')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
personId: mapValueOfType<String>(json, r'personId')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
);
}
return null;
}
static List<AssetFaceCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceCreateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFaceCreateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFaceCreateDto> mapFromJson(dynamic json) {
final map = <String, AssetFaceCreateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFaceCreateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFaceCreateDto-objects as value to a dart map
static Map<String, List<AssetFaceCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceCreateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFaceCreateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'height',
'imageHeight',
'imageWidth',
'personId',
'width',
'x',
'y',
};
}

View file

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFaceDeleteDto {
/// Returns a new [AssetFaceDeleteDto] instance.
AssetFaceDeleteDto({
required this.force,
});
bool force;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFaceDeleteDto &&
other.force == force;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(force.hashCode);
@override
String toString() => 'AssetFaceDeleteDto[force=$force]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'force'] = this.force;
return json;
}
/// Returns a new [AssetFaceDeleteDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceDeleteDto? fromJson(dynamic value) {
upgradeDto(value, "AssetFaceDeleteDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFaceDeleteDto(
force: mapValueOfType<bool>(json, r'force')!,
);
}
return null;
}
static List<AssetFaceDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceDeleteDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFaceDeleteDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFaceDeleteDto> mapFromJson(dynamic json) {
final map = <String, AssetFaceDeleteDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFaceDeleteDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFaceDeleteDto-objects as value to a dart map
static Map<String, List<AssetFaceDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceDeleteDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFaceDeleteDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'force',
};
}

View file

@ -2428,9 +2428,85 @@
"tags": [
"Faces"
]
},
"post": {
"operationId": "createFace",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetFaceCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Faces"
]
}
},
"/faces/{id}": {
"delete": {
"operationId": "deleteFace",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetFaceDeleteDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Faces"
]
},
"put": {
"operationId": "reassignFacesById",
"parameters": [
@ -8172,6 +8248,58 @@
],
"type": "object"
},
"AssetFaceCreateDto": {
"properties": {
"assetId": {
"format": "uuid",
"type": "string"
},
"height": {
"type": "integer"
},
"imageHeight": {
"type": "integer"
},
"imageWidth": {
"type": "integer"
},
"personId": {
"format": "uuid",
"type": "string"
},
"width": {
"type": "integer"
},
"x": {
"type": "integer"
},
"y": {
"type": "integer"
}
},
"required": [
"assetId",
"height",
"imageHeight",
"imageWidth",
"personId",
"width",
"x",
"y"
],
"type": "object"
},
"AssetFaceDeleteDto": {
"properties": {
"force": {
"type": "boolean"
}
},
"required": [
"force"
],
"type": "object"
},
"AssetFaceResponseDto": {
"properties": {
"boundingBoxX1": {

View file

@ -523,6 +523,19 @@ export type AssetFaceResponseDto = {
person: (PersonResponseDto) | null;
sourceType?: SourceType;
};
export type AssetFaceCreateDto = {
assetId: string;
height: number;
imageHeight: number;
imageWidth: number;
personId: string;
width: number;
x: number;
y: number;
};
export type AssetFaceDeleteDto = {
force: boolean;
};
export type FaceDto = {
id: string;
};
@ -2029,6 +2042,25 @@ export function getFaces({ id }: {
...opts
}));
}
export function createFace({ assetFaceCreateDto }: {
assetFaceCreateDto: AssetFaceCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/faces", oazapfts.json({
...opts,
method: "POST",
body: assetFaceCreateDto
})));
}
export function deleteFace({ id, assetFaceDeleteDto }: {
id: string;
assetFaceDeleteDto: AssetFaceDeleteDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/faces/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "DELETE",
body: assetFaceDeleteDto
})));
}
export function reassignFacesById({ id, faceDto }: {
id: string;
faceDto: FaceDto;

View file

@ -1,7 +1,13 @@
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
import {
AssetFaceCreateDto,
AssetFaceDeleteDto,
AssetFaceResponseDto,
FaceDto,
PersonResponseDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
@ -12,6 +18,12 @@ import { UUIDParamDto } from 'src/validation';
export class FaceController {
constructor(private service: PersonService) {}
@Post()
@Authenticated({ permission: Permission.FACE_CREATE })
createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) {
return this.service.createFace(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.FACE_READ })
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
@ -27,4 +39,10 @@ export class FaceController {
): Promise<PersonResponseDto> {
return this.service.reassignFacesById(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.FACE_DELETE })
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) {
return this.service.deleteFace(auth, id, dto);
}
}

7
server/src/db.d.ts vendored
View file

@ -88,6 +88,7 @@ export interface AssetFaces {
boundingBoxX2: Generated<number>;
boundingBoxY1: Generated<number>;
boundingBoxY2: Generated<number>;
deletedAt: Timestamp | null;
id: Generated<string>;
imageHeight: Generated<number>;
imageWidth: Generated<number>;
@ -334,6 +335,11 @@ export interface SocketIoAttachments {
payload: Buffer | null;
}
export interface SystemConfig {
key: string;
value: string | null;
}
export interface SystemMetadata {
key: string;
value: Json;
@ -448,6 +454,7 @@ export interface DB {
shared_links: SharedLinks;
smart_search: SmartSearch;
socket_io_attachments: SocketIoAttachments;
system_config: SystemConfig;
system_metadata: SystemMetadata;
tag_asset: TagAsset;
tags: Tags;

View file

@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator';
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
import { DateTime } from 'luxon';
import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@ -164,6 +164,43 @@ export class AssetFaceUpdateItem {
assetId!: string;
}
export class AssetFaceCreateDto extends AssetFaceUpdateItem {
@ApiProperty({ type: 'integer' })
@IsNotEmpty()
@IsNumber()
imageWidth!: number;
@ApiProperty({ type: 'integer' })
@IsNotEmpty()
@IsNumber()
imageHeight!: number;
@ApiProperty({ type: 'integer' })
@IsNotEmpty()
@IsNumber()
x!: number;
@ApiProperty({ type: 'integer' })
@IsNotEmpty()
@IsNumber()
y!: number;
@ApiProperty({ type: 'integer' })
@IsNotEmpty()
@IsNumber()
width!: number;
@ApiProperty({ type: 'integer' })
@IsNotEmpty()
@IsNumber()
height!: number;
}
export class AssetFaceDeleteDto {
@IsNotEmpty()
force!: boolean;
}
export class PersonStatisticsResponseDto {
@ApiProperty({ type: 'integer' })
assets!: number;

View file

@ -50,4 +50,7 @@ export class AssetFaceEntity {
nullable: true,
})
person!: PersonEntity | null;
@Column({ type: 'timestamptz' })
deletedAt!: Date | null;
}

View file

@ -202,10 +202,14 @@ export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch'));
}
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {
return jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as(
'faces',
);
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
return jsonArrayFrom(
eb
.selectFrom('asset_faces')
.selectAll()
.whereRef('asset_faces.assetId', '=', 'assets.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
).as('faces');
}
export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileType) {
@ -218,11 +222,12 @@ export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileT
).as('files');
}
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>) {
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
return eb
.selectFrom('asset_faces')
.leftJoin('person', 'person.id', 'asset_faces.personId')
.whereRef('asset_faces.assetId', '=', 'assets.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null))
.select((eb) =>
eb
.fn('jsonb_agg', [

View file

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDeletedAtColumnToAssetFacesTable1739466714036 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_faces
ADD COLUMN "deletedAt" TIMESTAMP WITH TIME ZONE DEFAULT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_faces
DROP COLUMN "deletedAt"
`);
}
}

View file

@ -96,6 +96,7 @@ select
left join "person" on "person"."id" = "asset_faces"."personId"
where
"asset_faces"."assetId" = "assets"."id"
and "asset_faces"."deletedAt" is null
) as "faces",
(
select

View file

@ -42,6 +42,8 @@ select
from
"person"
left join "asset_faces" on "asset_faces"."personId" = "person"."id"
where
"asset_faces"."deletedAt" is null
group by
"person"."id"
having
@ -67,6 +69,7 @@ from
"asset_faces"
where
"asset_faces"."assetId" = $1
and "asset_faces"."deletedAt" is null
order by
"asset_faces"."boundingBoxX1" asc
@ -90,6 +93,7 @@ from
"asset_faces"
where
"asset_faces"."id" = $1
and "asset_faces"."deletedAt" is null
-- PersonRepository.getFaceByIdWithAssets
select
@ -124,6 +128,7 @@ from
"asset_faces"
where
"asset_faces"."id" = $1
and "asset_faces"."deletedAt" is null
-- PersonRepository.reassignFace
update "asset_faces"
@ -169,6 +174,8 @@ from
and "asset_faces"."personId" = $1
and "assets"."isArchived" = $2
and "assets"."deletedAt" is null
where
"asset_faces"."deletedAt" is null
-- PersonRepository.getNumberOfPeople
select
@ -185,6 +192,7 @@ from
and "assets"."isArchived" = $2
where
"person"."ownerId" = $3
and "asset_faces"."deletedAt" is null
-- PersonRepository.refreshFaces
with
@ -235,6 +243,7 @@ from
where
"asset_faces"."assetId" in ($1)
and "asset_faces"."personId" in ($2)
and "asset_faces"."deletedAt" is null
-- PersonRepository.getRandomFace
select
@ -243,9 +252,22 @@ from
"asset_faces"
where
"asset_faces"."personId" = $1
and "asset_faces"."deletedAt" is null
-- PersonRepository.getLatestFaceDate
select
max("asset_job_status"."facesRecognizedAt")::text as "latestDate"
from
"asset_job_status"
-- PersonRepository.deleteAssetFace
delete from "asset_faces"
where
"asset_faces"."id" = $1
-- PersonRepository.softDeleteAssetFaces
update "asset_faces"
set
"deletedAt" = $1
where
"asset_faces"."id" = $2

View file

@ -132,7 +132,7 @@ export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffli
export interface GetByIdsRelations {
exifInfo?: boolean;
faces?: { person?: boolean };
faces?: { person?: boolean; withDeleted?: boolean };
files?: boolean;
library?: boolean;
owner?: boolean;
@ -262,7 +262,11 @@ export class AssetRepository {
.selectAll('assets')
.where('assets.id', '=', anyUuid(ids))
.$if(!!exifInfo, withExif)
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces))
.$if(!!faces, (qb) =>
qb.select((eb) =>
faces?.person ? withFacesAndPeople(eb, faces.withDeleted) : withFaces(eb, faces?.withDeleted),
),
)
.$if(!!files, (qb) => qb.select(withFiles))
.$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner))

View file

@ -130,6 +130,7 @@ export class PersonRepository {
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.where('asset_faces.deletedAt', 'is', null)
.stream() as AsyncIterableIterator<AssetFaceEntity>;
}
@ -161,6 +162,7 @@ export class PersonRepository {
.on('assets.deletedAt', 'is', null),
)
.where('person.ownerId', '=', userId)
.where('asset_faces.deletedAt', 'is', null)
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
@ -212,6 +214,7 @@ export class PersonRepository {
.selectFrom('person')
.selectAll('person')
.leftJoin('asset_faces', 'asset_faces.personId', 'person.id')
.where('asset_faces.deletedAt', 'is', null)
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
.groupBy('person.id')
.execute() as Promise<PersonEntity[]>;
@ -224,6 +227,7 @@ export class PersonRepository {
.selectAll('asset_faces')
.select(withPerson)
.where('asset_faces.assetId', '=', assetId)
.where('asset_faces.deletedAt', 'is', null)
.orderBy('asset_faces.boundingBoxX1', 'asc')
.execute() as Promise<AssetFaceEntity[]>;
}
@ -236,6 +240,7 @@ export class PersonRepository {
.selectAll('asset_faces')
.select(withPerson)
.where('asset_faces.id', '=', id)
.where('asset_faces.deletedAt', 'is', null)
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
}
@ -253,6 +258,7 @@ export class PersonRepository {
.select(withAsset)
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
.where('asset_faces.id', '=', id)
.where('asset_faces.deletedAt', 'is', null)
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
}
@ -317,6 +323,7 @@ export class PersonRepository {
.on('assets.deletedAt', 'is', null),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
.where('asset_faces.deletedAt', 'is', null)
.executeTakeFirst();
return {
@ -330,6 +337,7 @@ export class PersonRepository {
.selectFrom('person')
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
.where('person.ownerId', '=', userId)
.where('asset_faces.deletedAt', 'is', null)
.innerJoin('assets', (join) =>
join
.onRef('assets.id', '=', 'asset_faces.assetId')
@ -434,6 +442,7 @@ export class PersonRepository {
.select(withPerson)
.where('asset_faces.assetId', 'in', assetIds)
.where('asset_faces.personId', 'in', personIds)
.where('asset_faces.deletedAt', 'is', null)
.execute() as Promise<AssetFaceEntity[]>;
}
@ -443,6 +452,7 @@ export class PersonRepository {
.selectFrom('asset_faces')
.selectAll('asset_faces')
.where('asset_faces.personId', '=', personId)
.where('asset_faces.deletedAt', 'is', null)
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
}
@ -456,6 +466,20 @@ export class PersonRepository {
return result?.latestDate;
}
async createAssetFace(face: Insertable<AssetFaces>): Promise<void> {
await this.db.insertInto('asset_faces').values(face).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteAssetFace(id: string): Promise<void> {
await this.db.deleteFrom('asset_faces').where('asset_faces.id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async softDeleteAssetFaces(id: string): Promise<void> {
await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute();
}
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
await sql`REINDEX TABLE asset_faces`.execute(this.db);

View file

@ -5,9 +5,13 @@ import { OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceCreateDto,
AssetFaceDeleteDto,
AssetFaceResponseDto,
AssetFaceUpdateDto,
FaceDto,
mapFaces,
mapPerson,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
@ -16,8 +20,6 @@ import {
PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto,
mapFaces,
mapPerson,
} from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
@ -295,7 +297,7 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED;
}
const relations = { exifInfo: true, faces: { person: false }, files: true };
const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true };
const [asset] = await this.assetRepository.getByIds([id], relations);
const { previewFile } = getAssetFiles(asset.files);
if (!asset || !previewFile) {
@ -717,4 +719,29 @@ export class PersonService extends BaseService {
height: newHalfSize * 2,
};
}
// TODO return a asset face response
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([
this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [dto.personId] }),
]);
await this.personRepository.createAssetFace({
personId: dto.personId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
boundingBoxX1: dto.x,
boundingBoxX2: dto.x + dto.width,
boundingBoxY1: dto.y,
boundingBoxY2: dto.y + dto.height,
});
}
async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] });
return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id);
}
}

View file

@ -217,6 +217,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
}
case Permission.FACE_DELETE: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.TAG_ASSET:
case Permission.TAG_READ:
case Permission.TAG_UPDATE:

View file

@ -20,8 +20,9 @@ export const faceStub = {
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
deletedAt: new Date(),
}),
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
primaryFace1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId2',
assetId: assetStub.image.id,
asset: assetStub.image,
@ -35,8 +36,9 @@ export const faceStub = {
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
mergeFace1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId3',
assetId: assetStub.image.id,
asset: assetStub.image,
@ -50,8 +52,9 @@ export const faceStub = {
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
start: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId5',
assetId: assetStub.image.id,
asset: assetStub.image,
@ -65,8 +68,9 @@ export const faceStub = {
imageWidth: 2160,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
middle: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId6',
assetId: assetStub.image.id,
asset: assetStub.image,
@ -80,8 +84,9 @@ export const faceStub = {
imageWidth: 400,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
end: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId7',
assetId: assetStub.image.id,
asset: assetStub.image,
@ -95,6 +100,7 @@ export const faceStub = {
imageWidth: 500,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
noPerson1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId8',
@ -110,6 +116,7 @@ export const faceStub = {
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
noPerson2: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',
@ -125,6 +132,7 @@ export const faceStub = {
imageWidth: 1024,
sourceType: SourceType.MACHINE_LEARNING,
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
deletedAt: null,
}),
fromExif1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',
@ -139,6 +147,7 @@ export const faceStub = {
imageHeight: 500,
imageWidth: 400,
sourceType: SourceType.EXIF,
deletedAt: null,
}),
fromExif2: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',
@ -153,5 +162,6 @@ export const faceStub = {
imageHeight: 1024,
imageWidth: 1024,
sourceType: SourceType.EXIF,
deletedAt: null,
}),
};

View file

@ -33,5 +33,9 @@ export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepo
getFaceByIdWithAssets: vitest.fn(),
getNumberOfPeople: vitest.fn(),
getLatestFaceDate: vitest.fn(),
createAssetFace: vitest.fn(),
deleteAssetFace: vitest.fn(),
softDeleteAssetFaces: vitest.fn(),
};
};

989
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -78,6 +78,7 @@
"@photo-sphere-viewer/video-plugin": "^5.11.5",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"handlebars": "^4.7.8",
"intl-messageformat": "^10.7.11",
"lodash-es": "^4.17.21",

View file

@ -25,7 +25,6 @@
type ExifResponseDto,
} from '@immich/sdk';
import {
mdiAccountOff,
mdiCalendar,
mdiCameraIris,
mdiClose,
@ -34,6 +33,7 @@
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
@ -46,6 +46,7 @@
import AlbumListItemDetails from './album-list-item-details.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
interface Props {
asset: AssetResponseDto;
@ -186,20 +187,11 @@
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
{#if !isSharedLink() && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<h2>{$t('people').toUpperCase()}</h2>
<div class="flex gap-2 items-center">
{#if unassignedFaces.length > 0}
<Icon
ariaLabel={$t('asset_has_unassigned_faces')}
title={$t('asset_has_unassigned_faces')}
color="currentColor"
path={mdiAccountOff}
size="24"
/>
{/if}
{#if people.some((person) => person.isHidden)}
<CircleIconButton
title={$t('show_hidden_people')}
@ -210,13 +202,24 @@
/>
{/if}
<CircleIconButton
title={$t('edit_people')}
icon={mdiPencil}
title={$t('tag_people')}
icon={mdiPlus}
padding="1"
size="20"
buttonSize="32"
onclick={() => (showEditFaces = true)}
onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<CircleIconButton
title={$t('edit_people')}
icon={mdiPencil}
padding="1"
size="20"
buttonSize="32"
onclick={() => (showEditFaces = true)}
/>
{/if}
</div>
</div>

View file

@ -0,0 +1,310 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { notificationController } from '$lib/components/shared-components/notification/notification';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { onMount } from 'svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handleError } from '$lib/utils/handle-error';
interface Props {
imgElement: HTMLImageElement;
containerWidth: number;
containerHeight: number;
assetId: string;
}
let { imgElement, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
const configureControlStyle = () => {
InteractiveFabricObject.ownDefaults = {
...InteractiveFabricObject.ownDefaults,
cornerStyle: 'circle',
cornerColor: 'rgb(153,166,251)',
cornerSize: 10,
padding: 8,
transparentCorners: false,
lockRotation: true,
hasBorders: true,
};
};
const setupCanvas = () => {
if (!canvasEl || !imgElement) {
return;
}
canvas = new Canvas(canvasEl);
configureControlStyle();
faceRect = new Rect({
fill: 'rgba(66,80,175,0.25)',
stroke: 'rgb(66,80,175)',
strokeWidth: 2,
strokeUniform: true,
width: 112,
height: 112,
objectCaching: true,
rx: 8,
ry: 8,
});
canvas.add(faceRect);
canvas.setActiveObject(faceRect);
};
onMount(async () => {
setupCanvas();
await getPeople();
});
$effect(() => {
const { actualWidth, actualHeight } = getContainedSize(imgElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const imageBoundingBox = {
top: offsetArea.height,
left: offsetArea.width,
width: containerWidth - offsetArea.width * 2,
height: containerHeight - offsetArea.height * 2,
};
if (!canvas) {
return;
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
});
if (!faceRect) {
return;
}
faceRect.set({
top: imageBoundingBox.top + 200,
left: imageBoundingBox.left + 200,
});
faceRect.setCoords();
positionFaceSelector();
});
const getContainedSize = (img: HTMLImageElement): { actualWidth: number; actualHeight: number } => {
const ratio = img.naturalWidth / img.naturalHeight;
let actualWidth = img.height * ratio;
let actualHeight = img.height;
if (actualWidth > img.width) {
actualWidth = img.width;
actualHeight = img.width / ratio;
}
return { actualWidth, actualHeight };
};
const cancel = () => {
isFaceEditMode.value = false;
};
let page = $state(1);
let candidates = $state<PersonResponseDto[]>([]);
const getPeople = async () => {
const { hasNextPage, people, total } = await getAllPeople({ page, size: 250, withHidden: false });
if (candidates.length === total) {
return;
}
candidates = [...candidates, ...people];
if (hasNextPage) {
page++;
}
};
const positionFaceSelector = () => {
if (!faceRect || !faceSelectorEl) {
return;
}
const rect = faceRect.getBoundingRect();
const selectorWidth = faceSelectorEl.offsetWidth;
const selectorHeight = faceSelectorEl.offsetHeight;
const spaceAbove = rect.top;
const spaceBelow = containerHeight - (rect.top + rect.height);
const spaceLeft = rect.left;
const spaceRight = containerWidth - (rect.left + rect.width);
let top, left;
if (
spaceBelow >= selectorHeight ||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
) {
top = rect.top + rect.height + 15;
left = rect.left;
} else if (
spaceAbove >= selectorHeight ||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
) {
top = rect.top - selectorHeight - 15;
left = rect.left;
} else if (
spaceRight >= selectorWidth ||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
) {
top = rect.top;
left = rect.left + rect.width + 15;
} else {
top = rect.top;
left = rect.left - selectorWidth - 15;
}
if (left + selectorWidth > containerWidth) {
left = containerWidth - selectorWidth - 15;
}
if (left < 0) {
left = 15;
}
if (top + selectorHeight > containerHeight) {
top = containerHeight - selectorHeight - 15;
}
if (top < 0) {
top = 15;
}
faceSelectorEl.style.top = `${top}px`;
faceSelectorEl.style.left = `${left}px`;
};
$effect(() => {
if (faceRect) {
faceRect.on('moving', positionFaceSelector);
faceRect.on('scaling', positionFaceSelector);
}
});
const getFaceCroppedCoordinates = () => {
if (!faceRect || !imgElement) {
return;
}
const { left, top, width, height } = faceRect.getBoundingRect();
const { actualWidth, actualHeight } = getContainedSize(imgElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const x1Coeff = (left - offsetArea.width) / actualWidth;
const y1Coeff = (top - offsetArea.height) / actualHeight;
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
// transpose to the natural image location
const x1 = x1Coeff * imgElement.naturalWidth;
const y1 = y1Coeff * imgElement.naturalHeight;
const x2 = x2Coeff * imgElement.naturalWidth;
const y2 = y2Coeff * imgElement.naturalHeight;
return {
imageWidth: imgElement.naturalWidth,
imageHeight: imgElement.naturalHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
};
const tagFace = async (person: PersonResponseDto) => {
try {
const data = getFaceCroppedCoordinates();
if (!data) {
notificationController.show({
message: 'Error tagging face - cannot get bounding box coordinates',
});
return;
}
const isConfirmed = await dialogController.show({
prompt: `Do you want to tag this face as ${person.name}?`,
});
if (!isConfirmed) {
return;
}
await createFace({
assetFaceCreateDto: {
assetId,
personId: person.id,
...data,
},
});
await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
isFaceEditMode.value = false;
}
};
</script>
<div class="absolute left-0 top-0">
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 left-0"></canvas>
<div
id="face-selector"
bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] left-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200"
>
<p class="text-center text-sm">Select a person to tag</p>
<div class="max-h-[250px] overflow-y-auto mt-2">
<div class="mt-2 rounded-lg">
{#each candidates as person}
<button
onclick={() => tagFace(person)}
type="button"
class="w-full flex place-items-center gap-2 rounded-lg pl-1 pr-4 py-2 hover:bg-immich-primary/25"
>
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="30px"
heightStyle="30px"
/>
<p class="text-sm">
{person.name}
</p>
</button>
{/each}
</div>
</div>
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button>
</div>
</div>

View file

@ -7,6 +7,14 @@ import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
import type { MockInstance } from 'vitest';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserver;
vi.mock('$lib/utils', async (originalImport) => {
const meta = await originalImport<typeof import('$lib/utils')>();
return {

View file

@ -2,7 +2,6 @@
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { photoViewer } from '$lib/stores/assets.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
@ -19,6 +18,9 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import { photoViewerImgElement } from '$lib/stores/assets.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
interface Props {
asset: AssetResponseDto;
@ -91,7 +93,7 @@
}
try {
await copyImageToClipboard($photoViewer ?? assetFileUrl);
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: $t('copied_image_to_clipboard'),
@ -106,6 +108,12 @@
$zoomed = $zoomed ? false : true;
};
$effect(() => {
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
zoomToggle();
}
});
const onCopyShortcut = (event: KeyboardEvent) => {
if (globalThis.getSelection()?.type === 'Range') {
return;
@ -159,6 +167,9 @@
});
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
let containerWidth = $state(0);
let containerHeight = $state(0);
</script>
<svelte:window
@ -172,7 +183,12 @@
{/if}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
<div bind:this={element} class="relative h-full select-none">
<div
bind:this={element}
class="relative h-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
<img
style="display:none"
src={imageLoaderUrl}
@ -201,7 +217,7 @@
/>
{/if}
<img
bind:this={$photoViewer}
bind:this={$photoViewerImgElement}
src={assetFileUrl}
alt={$getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None
@ -209,13 +225,17 @@
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
</div>
{#if isFaceEditMode.value}
<FaceEditor imgElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
</div>

View file

@ -64,7 +64,7 @@
transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg',
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
light: 'bg-white hover:bg-[#d3d3d3]',
red: 'text-red-400 hover:bg-[#d3d3d3]',
red: 'text-red-400 bg-red-100 hover:bg-[#d3d3d3]',
dark: 'bg-[#202123] hover:bg-[#d3d3d3]',
alert: 'text-[#ff0000] hover:text-white',
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',

View file

@ -6,7 +6,7 @@
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { photoViewer } from '$lib/stores/assets.store';
import { photoViewerImgElement } from '$lib/stores/assets.store';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
@ -62,7 +62,7 @@
const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewer);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement);
onCreatePerson(newFeaturePhoto);

View file

@ -13,9 +13,10 @@
AssetTypeEnum,
type AssetFaceResponseDto,
type PersonResponseDto,
deleteFace,
} from '@immich/sdk';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart } from '@mdi/js';
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { onMount } from 'svelte';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
@ -24,8 +25,10 @@
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
import { photoViewer } from '$lib/stores/assets.store';
import { photoViewerImgElement } from '$lib/stores/assets.store';
import { t } from 'svelte-i18n';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
interface Props {
assetId: string;
@ -163,6 +166,30 @@
editedFace = face;
showSelectedFaces = true;
};
const deleteAssetFace = async (face: AssetFaceResponseDto) => {
try {
if (!face.person) {
return;
}
const isConfirmed = await dialogController.show({
prompt: $t('confirm_delete_face', { values: { name: face.person.name } }),
});
if (!isConfirmed) {
return;
}
await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } });
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}
};
</script>
<section
@ -242,7 +269,7 @@
hidden={face.person.isHidden}
/>
{:else}
{#await zoomImageToBase64(face, assetId, assetType, $photoViewer)}
{#await zoomImageToBase64(face, assetId, assetType, $photoViewerImgElement)}
<ImageThumbnail
curve
shadow
@ -308,6 +335,19 @@
</div>
{/if}
</div>
{#if face.person != null}
<div class="absolute -right-[5px] top-[25px] h-[20px] w-[20px] rounded-full">
<CircleIconButton
color="red"
icon={mdiTrashCan}
title={$t('delete_face')}
size="18"
padding="1"
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
onclick={() => deleteAssetFace(face)}
/>
</div>
{/if}
</div>
</div>
{/each}

View file

@ -148,7 +148,7 @@ interface UpdateStackAssets {
values: string[];
}
export const photoViewer = writable<HTMLImageElement | null>(null);
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;

View file

@ -0,0 +1 @@
export const isFaceEditMode = $state({ value: false });

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
@ -22,6 +23,7 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { preferences, user } from '$lib/stores/user.store';
import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
@ -68,6 +70,10 @@
onDestroy(() => {
assetStore.destroy();
});
beforeNavigate(() => {
isFaceEditMode.value = false;
});
</script>
{#if assetInteraction.selectionActive}