From 38438c8d9a4b19e8563327b26d098bec6d862711 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Mon, 11 May 2026 16:05:40 -0500 Subject: [PATCH] refactor!: remove asset faces from AssetResponseDto (#27779) * refactor!: remove faces from `people` in AssetResposnseDto * chore: tests * chore: e2e generator * chore: code review readonly * chore: code review changes * chore: cleanup * fix: openapi * chore: format --------- Co-authored-by: Jason Rasmussen --- e2e/src/specs/server/api/asset.e2e-spec.ts | 74 ------- .../ui/generators/timeline/rest-response.ts | 1 - .../ui/mock-network/broken-asset-network.ts | 1 - mobile/openapi/README.md | 2 - mobile/openapi/lib/api.dart | 2 - mobile/openapi/lib/api_client.dart | 4 - ...sset_face_without_person_response_dto.dart | 189 ---------------- .../openapi/lib/model/asset_response_dto.dart | 13 +- .../model/person_with_faces_response_dto.dart | 202 ------------------ open-api/immich-openapi-specs.json | 156 +------------- packages/sdk/src/fetch-client.ts | 51 +---- server/src/dtos/asset-response.dto.spec.ts | 192 ----------------- server/src/dtos/asset-response.dto.ts | 44 +--- server/src/dtos/person.dto.ts | 25 +-- .../asset-viewer/AssetViewer.svelte | 5 + .../asset-viewer/DetailPanelPeople.svelte | 15 +- .../asset-viewer/PhotoViewer.svelte | 10 +- .../actions/SetPersonFeaturedAction.svelte | 2 +- .../lib/managers/AssetCacheManager.svelte.ts | 13 +- web/src/lib/stores/face.svelte.ts | 74 +++++++ web/src/lib/stores/ocr.svelte.spec.ts | 1 + 21 files changed, 134 insertions(+), 942 deletions(-) delete mode 100644 mobile/openapi/lib/model/asset_face_without_person_response_dto.dart delete mode 100644 mobile/openapi/lib/model/person_with_faces_response_dto.dart delete mode 100644 server/src/dtos/asset-response.dto.spec.ts create mode 100644 web/src/lib/stores/face.svelte.ts diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index 3fbacd5bf6..010b096c4d 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -7,7 +7,6 @@ import { getMyUser, LoginResponseDto, SharedLinkType, - updateConfig, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; @@ -24,7 +23,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`; -const facesAssetDir = `${testAssetDir}/metadata/faces`; const readTags = async (bytes: Buffer, filename: string) => { const filepath = join(tempDir, filename); @@ -185,78 +183,6 @@ describe('/asset', () => { }); }); - describe('faces', () => { - const metadataFaceTests = [ - { - description: 'without orientation', - filename: 'portrait.jpg', - }, - { - description: 'adjusting face regions to orientation', - filename: 'portrait-orientation-6.jpg', - }, - ]; - // should produce same resulting face region coordinates for any orientation - const expectedFaces = [ - { - name: 'Marie Curie', - birthDate: null, - isHidden: false, - faces: [ - { - imageHeight: 700, - imageWidth: 840, - boundingBoxX1: 261, - boundingBoxX2: 356, - boundingBoxY1: 146, - boundingBoxY2: 284, - sourceType: 'exif', - }, - ], - }, - { - name: 'Pierre Curie', - birthDate: null, - isHidden: false, - faces: [ - { - imageHeight: 700, - imageWidth: 840, - boundingBoxX1: 536, - boundingBoxX2: 618, - boundingBoxY1: 83, - boundingBoxY2: 252, - sourceType: 'exif', - }, - ], - }, - ]; - - it.each(metadataFaceTests)('should get the asset faces from $filename $description', async ({ filename }) => { - const config = await utils.getSystemConfig(admin.accessToken); - config.metadata.faces.import = true; - await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); - - const facesAsset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: await readFile(`${facesAssetDir}/${filename}`), - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: facesAsset.id }); - - const { status, body } = await request(app) - .get(`/assets/${facesAsset.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body.id).toEqual(facesAsset.id); - const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name)); - expect(sortedPeople).toMatchObject(expectedFaces); - }); - }); - it('should work with a shared link', async () => { const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, diff --git a/e2e/src/ui/generators/timeline/rest-response.ts b/e2e/src/ui/generators/timeline/rest-response.ts index 83a60556be..bc55003c90 100644 --- a/e2e/src/ui/generators/timeline/rest-response.ts +++ b/e2e/src/ui/generators/timeline/rest-response.ts @@ -338,7 +338,6 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons livePhotoVideoId: asset.livePhotoVideoId, tags: [], people: [], - unassignedFaces: [], stack: asset.stack, isOffline: false, hasMetadata: true, diff --git a/e2e/src/ui/mock-network/broken-asset-network.ts b/e2e/src/ui/mock-network/broken-asset-network.ts index ce66412e61..2137cdd90f 100644 --- a/e2e/src/ui/mock-network/broken-asset-network.ts +++ b/e2e/src/ui/mock-network/broken-asset-network.ts @@ -66,7 +66,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => { livePhotoVideoId: null, tags: [], people: [], - unassignedFaces: [], stack: undefined, isOffline: false, hasMetadata: true, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 93b3a45eb0..d373fe2656 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -357,7 +357,6 @@ Class | Method | HTTP request | Description - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md) - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md) - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md) - - [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md) - [AssetIdErrorReason](doc//AssetIdErrorReason.md) - [AssetIdsDto](doc//AssetIdsDto.md) - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) @@ -483,7 +482,6 @@ Class | Method | HTTP request | Description - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) - - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PinCodeChangeDto](doc//PinCodeChangeDto.md) - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index fc554b4970..1906c5087f 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -105,7 +105,6 @@ 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'; -part 'model/asset_face_without_person_response_dto.dart'; part 'model/asset_id_error_reason.dart'; part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; @@ -231,7 +230,6 @@ part 'model/person_create_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; -part 'model/person_with_faces_response_dto.dart'; part 'model/pin_code_change_dto.dart'; part 'model/pin_code_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bb006fdd65..c0dadadc12 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -256,8 +256,6 @@ class ApiClient { return AssetFaceUpdateDto.fromJson(value); case 'AssetFaceUpdateItem': return AssetFaceUpdateItem.fromJson(value); - case 'AssetFaceWithoutPersonResponseDto': - return AssetFaceWithoutPersonResponseDto.fromJson(value); case 'AssetIdErrorReason': return AssetIdErrorReasonTypeTransformer().decode(value); case 'AssetIdsDto': @@ -508,8 +506,6 @@ class ApiClient { return PersonStatisticsResponseDto.fromJson(value); case 'PersonUpdateDto': return PersonUpdateDto.fromJson(value); - case 'PersonWithFacesResponseDto': - return PersonWithFacesResponseDto.fromJson(value); case 'PinCodeChangeDto': return PinCodeChangeDto.fromJson(value); case 'PinCodeResetDto': diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart deleted file mode 100644 index 4a4a2a658e..0000000000 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ /dev/null @@ -1,189 +0,0 @@ -// -// 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 AssetFaceWithoutPersonResponseDto { - /// Returns a new [AssetFaceWithoutPersonResponseDto] instance. - AssetFaceWithoutPersonResponseDto({ - required this.boundingBoxX1, - required this.boundingBoxX2, - required this.boundingBoxY1, - required this.boundingBoxY2, - required this.id, - required this.imageHeight, - required this.imageWidth, - this.sourceType, - }); - - /// Bounding box X1 coordinate - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int boundingBoxX1; - - /// Bounding box X2 coordinate - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int boundingBoxX2; - - /// Bounding box Y1 coordinate - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int boundingBoxY1; - - /// Bounding box Y2 coordinate - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int boundingBoxY2; - - /// Face ID - String id; - - /// Image height in pixels - /// - /// Minimum value: 0 - /// Maximum value: 9007199254740991 - int imageHeight; - - /// Image width in pixels - /// - /// Minimum value: 0 - /// Maximum value: 9007199254740991 - int imageWidth; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - SourceType? sourceType; - - @override - bool operator ==(Object other) => identical(this, other) || other is AssetFaceWithoutPersonResponseDto && - other.boundingBoxX1 == boundingBoxX1 && - other.boundingBoxX2 == boundingBoxX2 && - other.boundingBoxY1 == boundingBoxY1 && - other.boundingBoxY2 == boundingBoxY2 && - other.id == id && - other.imageHeight == imageHeight && - other.imageWidth == imageWidth && - other.sourceType == sourceType; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (boundingBoxX1.hashCode) + - (boundingBoxX2.hashCode) + - (boundingBoxY1.hashCode) + - (boundingBoxY2.hashCode) + - (id.hashCode) + - (imageHeight.hashCode) + - (imageWidth.hashCode) + - (sourceType == null ? 0 : sourceType!.hashCode); - - @override - String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, sourceType=$sourceType]'; - - Map toJson() { - final json = {}; - json[r'boundingBoxX1'] = this.boundingBoxX1; - json[r'boundingBoxX2'] = this.boundingBoxX2; - json[r'boundingBoxY1'] = this.boundingBoxY1; - json[r'boundingBoxY2'] = this.boundingBoxY2; - json[r'id'] = this.id; - json[r'imageHeight'] = this.imageHeight; - json[r'imageWidth'] = this.imageWidth; - if (this.sourceType != null) { - json[r'sourceType'] = this.sourceType; - } else { - // json[r'sourceType'] = null; - } - return json; - } - - /// Returns a new [AssetFaceWithoutPersonResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) { - upgradeDto(value, "AssetFaceWithoutPersonResponseDto"); - if (value is Map) { - final json = value.cast(); - - return AssetFaceWithoutPersonResponseDto( - boundingBoxX1: mapValueOfType(json, r'boundingBoxX1')!, - boundingBoxX2: mapValueOfType(json, r'boundingBoxX2')!, - boundingBoxY1: mapValueOfType(json, r'boundingBoxY1')!, - boundingBoxY2: mapValueOfType(json, r'boundingBoxY2')!, - id: mapValueOfType(json, r'id')!, - imageHeight: mapValueOfType(json, r'imageHeight')!, - imageWidth: mapValueOfType(json, r'imageWidth')!, - sourceType: SourceType.fromJson(json[r'sourceType']), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AssetFaceWithoutPersonResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = AssetFaceWithoutPersonResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AssetFaceWithoutPersonResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = AssetFaceWithoutPersonResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'boundingBoxX1', - 'boundingBoxX2', - 'boundingBoxY1', - 'boundingBoxY2', - 'id', - 'imageHeight', - 'imageWidth', - }; -} - diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 7284c44580..eca87789ce 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -42,7 +42,6 @@ class AssetResponseDto { this.tags = const [], required this.thumbhash, required this.type, - this.unassignedFaces = const [], required this.updatedAt, required this.visibility, required this.width, @@ -139,7 +138,7 @@ class AssetResponseDto { /// Owner user ID String ownerId; - List people; + List people; /// Is resized /// @@ -159,8 +158,6 @@ class AssetResponseDto { AssetTypeEnum type; - List unassignedFaces; - /// The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. DateTime updatedAt; @@ -203,7 +200,6 @@ class AssetResponseDto { _deepEquality.equals(other.tags, tags) && other.thumbhash == thumbhash && other.type == type && - _deepEquality.equals(other.unassignedFaces, unassignedFaces) && other.updatedAt == updatedAt && other.visibility == visibility && other.width == width; @@ -240,13 +236,12 @@ class AssetResponseDto { (tags.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + - (unassignedFaces.hashCode) + (updatedAt.hashCode) + (visibility.hashCode) + (width == null ? 0 : width!.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -323,7 +318,6 @@ class AssetResponseDto { // json[r'thumbhash'] = null; } json[r'type'] = this.type; - json[r'unassignedFaces'] = this.unassignedFaces; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'visibility'] = this.visibility; if (this.width != null) { @@ -366,13 +360,12 @@ class AssetResponseDto { originalPath: mapValueOfType(json, r'originalPath')!, owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, - people: PersonWithFacesResponseDto.listFromJson(json[r'people']), + people: PersonResponseDto.listFromJson(json[r'people']), resized: mapValueOfType(json, r'resized'), stack: AssetStackResponseDto.fromJson(json[r'stack']), tags: TagResponseDto.listFromJson(json[r'tags']), thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, - unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, width: mapValueOfType(json, r'width'), diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart deleted file mode 100644 index f710dff8b9..0000000000 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ /dev/null @@ -1,202 +0,0 @@ -// -// 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 PersonWithFacesResponseDto { - /// Returns a new [PersonWithFacesResponseDto] instance. - PersonWithFacesResponseDto({ - required this.birthDate, - this.color, - this.faces = const [], - required this.id, - this.isFavorite, - required this.isHidden, - required this.name, - required this.thumbnailPath, - this.updatedAt, - }); - - /// Person date of birth - DateTime? birthDate; - - /// Person color (hex) - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - String? color; - - List faces; - - /// Person ID - String id; - - /// Is favorite - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isFavorite; - - /// Is hidden - bool isHidden; - - /// Person name - String name; - - /// Thumbnail path - String thumbnailPath; - - /// Last update date - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - DateTime? updatedAt; - - @override - bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto && - other.birthDate == birthDate && - other.color == color && - _deepEquality.equals(other.faces, faces) && - other.id == id && - other.isFavorite == isFavorite && - other.isHidden == isHidden && - other.name == name && - other.thumbnailPath == thumbnailPath && - other.updatedAt == updatedAt; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (birthDate == null ? 0 : birthDate!.hashCode) + - (color == null ? 0 : color!.hashCode) + - (faces.hashCode) + - (id.hashCode) + - (isFavorite == null ? 0 : isFavorite!.hashCode) + - (isHidden.hashCode) + - (name.hashCode) + - (thumbnailPath.hashCode) + - (updatedAt == null ? 0 : updatedAt!.hashCode); - - @override - String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; - - Map toJson() { - final json = {}; - if (this.birthDate != null) { - json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); - } else { - // json[r'birthDate'] = null; - } - if (this.color != null) { - json[r'color'] = this.color; - } else { - // json[r'color'] = null; - } - json[r'faces'] = this.faces; - json[r'id'] = this.id; - if (this.isFavorite != null) { - json[r'isFavorite'] = this.isFavorite; - } else { - // json[r'isFavorite'] = null; - } - json[r'isHidden'] = this.isHidden; - json[r'name'] = this.name; - json[r'thumbnailPath'] = this.thumbnailPath; - if (this.updatedAt != null) { - json[r'updatedAt'] = this.updatedAt!.toUtc().toIso8601String(); - } else { - // json[r'updatedAt'] = null; - } - return json; - } - - /// Returns a new [PersonWithFacesResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PersonWithFacesResponseDto? fromJson(dynamic value) { - upgradeDto(value, "PersonWithFacesResponseDto"); - if (value is Map) { - final json = value.cast(); - - return PersonWithFacesResponseDto( - birthDate: mapDateTime(json, r'birthDate', r''), - color: mapValueOfType(json, r'color'), - faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), - id: mapValueOfType(json, r'id')!, - isFavorite: mapValueOfType(json, r'isFavorite'), - isHidden: mapValueOfType(json, r'isHidden')!, - name: mapValueOfType(json, r'name')!, - thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, - updatedAt: mapDateTime(json, r'updatedAt', r''), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PersonWithFacesResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PersonWithFacesResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PersonWithFacesResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PersonWithFacesResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'birthDate', - 'faces', - 'id', - 'isHidden', - 'name', - 'thumbnailPath', - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c2717d1dfb..eb6ccbb9ea 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16022,6 +16022,7 @@ "type": "object" }, "AssetFaceResponseDto": { + "description": "Asset face with person", "properties": { "boundingBoxX1": { "description": "Bounding box X1 coordinate", @@ -16125,66 +16126,6 @@ ], "type": "object" }, - "AssetFaceWithoutPersonResponseDto": { - "description": "Asset face without person", - "properties": { - "boundingBoxX1": { - "description": "Bounding box X1 coordinate", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "boundingBoxX2": { - "description": "Bounding box X2 coordinate", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "boundingBoxY1": { - "description": "Bounding box Y1 coordinate", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "boundingBoxY2": { - "description": "Bounding box Y2 coordinate", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "id": { - "description": "Face ID", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" - }, - "imageHeight": { - "description": "Image height in pixels", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "imageWidth": { - "description": "Image width in pixels", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "sourceType": { - "$ref": "#/components/schemas/SourceType" - } - }, - "required": [ - "boundingBoxX1", - "boundingBoxX2", - "boundingBoxY1", - "boundingBoxY2", - "id", - "imageHeight", - "imageWidth" - ], - "type": "object" - }, "AssetIdErrorReason": { "description": "Error reason if failed", "enum": [ @@ -16755,7 +16696,7 @@ }, "people": { "items": { - "$ref": "#/components/schemas/PersonWithFacesResponseDto" + "$ref": "#/components/schemas/PersonResponseDto" }, "type": "array" }, @@ -16796,12 +16737,6 @@ "type": { "$ref": "#/components/schemas/AssetTypeEnum" }, - "unassignedFaces": { - "items": { - "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" - }, - "type": "array" - }, "updatedAt": { "description": "The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.", "format": "date-time", @@ -19764,93 +19699,6 @@ }, "type": "object" }, - "PersonWithFacesResponseDto": { - "properties": { - "birthDate": { - "description": "Person date of birth", - "format": "date", - "nullable": true, - "type": "string" - }, - "color": { - "description": "Person color (hex)", - "type": "string", - "x-immich-history": [ - { - "version": "v1.126.0", - "state": "Added" - }, - { - "version": "v2", - "state": "Stable" - } - ], - "x-immich-state": "Stable" - }, - "faces": { - "items": { - "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" - }, - "type": "array" - }, - "id": { - "description": "Person ID", - "type": "string" - }, - "isFavorite": { - "description": "Is favorite", - "type": "boolean", - "x-immich-history": [ - { - "version": "v1.126.0", - "state": "Added" - }, - { - "version": "v2", - "state": "Stable" - } - ], - "x-immich-state": "Stable" - }, - "isHidden": { - "description": "Is hidden", - "type": "boolean" - }, - "name": { - "description": "Person name", - "type": "string" - }, - "thumbnailPath": { - "description": "Thumbnail path", - "type": "string" - }, - "updatedAt": { - "description": "Last update date", - "format": "date-time", - "type": "string", - "x-immich-history": [ - { - "version": "v1.107.0", - "state": "Added" - }, - { - "version": "v2", - "state": "Stable" - } - ], - "x-immich-state": "Stable" - } - }, - "required": [ - "birthDate", - "faces", - "id", - "isHidden", - "name", - "thumbnailPath" - ], - "type": "object" - }, "PinCodeChangeDto": { "properties": { "newPinCode": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index c267d53a08..6e95ff7286 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -787,29 +787,11 @@ export type ExifResponseDto = { /** Time zone */ timeZone?: string | null; }; -export type AssetFaceWithoutPersonResponseDto = { - /** Bounding box X1 coordinate */ - boundingBoxX1: number; - /** Bounding box X2 coordinate */ - boundingBoxX2: number; - /** Bounding box Y1 coordinate */ - boundingBoxY1: number; - /** Bounding box Y2 coordinate */ - boundingBoxY2: number; - /** Face ID */ - id: string; - /** Image height in pixels */ - imageHeight: number; - /** Image width in pixels */ - imageWidth: number; - sourceType?: SourceType; -}; -export type PersonWithFacesResponseDto = { +export type PersonResponseDto = { /** Person date of birth */ birthDate: string | null; /** Person color (hex) */ color?: string; - faces: AssetFaceWithoutPersonResponseDto[]; /** Person ID */ id: string; /** Is favorite */ @@ -892,7 +874,7 @@ export type AssetResponseDto = { owner?: UserResponseDto; /** Owner user ID */ ownerId: string; - people?: PersonWithFacesResponseDto[]; + people?: PersonResponseDto[]; /** Is resized */ resized?: boolean; stack?: (AssetStackResponseDto) | null; @@ -900,7 +882,6 @@ export type AssetResponseDto = { /** Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting. */ thumbhash: string | null; "type": AssetTypeEnum; - unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; /** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */ updatedAt: string; visibility: AssetVisibility; @@ -1136,24 +1117,6 @@ export type DuplicateResolveDto = { /** List of duplicate groups to resolve */ groups: DuplicateResolveGroupDto[]; }; -export type PersonResponseDto = { - /** Person date of birth */ - birthDate: string | null; - /** Person color (hex) */ - color?: string; - /** Person ID */ - id: string; - /** Is favorite */ - isFavorite?: boolean; - /** Is hidden */ - isHidden: boolean; - /** Person name */ - name: string; - /** Thumbnail path */ - thumbnailPath: string; - /** Last update date */ - updatedAt?: string; -}; export type AssetFaceResponseDto = { /** Bounding box X1 coordinate */ boundingBoxX1: number; @@ -6971,11 +6934,6 @@ export enum AssetJobName { RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } -export enum SourceType { - MachineLearning = "machine-learning", - Exif = "exif", - Manual = "manual" -} export enum AssetTypeEnum { Image = "IMAGE", Video = "VIDEO", @@ -6997,6 +6955,11 @@ export enum AssetMediaSize { Preview = "preview", Thumbnail = "thumbnail" } +export enum SourceType { + MachineLearning = "machine-learning", + Exif = "exif", + Manual = "manual" +} export enum ManualJobName { PersonCleanup = "person-cleanup", TagCleanup = "tag-cleanup", diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts deleted file mode 100644 index 8e85b983c3..0000000000 --- a/server/src/dtos/asset-response.dto.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AssetEditAction } from 'src/dtos/editing.dto'; -import { AssetFaceFactory } from 'test/factories/asset-face.factory'; -import { AssetFactory } from 'test/factories/asset.factory'; -import { PersonFactory } from 'test/factories/person.factory'; -import { getForAsset } from 'test/mappers'; - -describe('mapAsset', () => { - describe('peopleWithFaces', () => { - it('should transform all faces when a person has multiple faces in the same image', () => { - const person = PersonFactory.create(); - const face1 = { - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageWidth: 1000, - imageHeight: 800, - }; - - const face2 = { - boundingBoxX1: 300, - boundingBoxY1: 400, - boundingBoxX2: 400, - boundingBoxY2: 500, - imageWidth: 1000, - imageHeight: 800, - }; - - const asset = AssetFactory.from() - .face(face1, (builder) => builder.person(person)) - .face(face2, (builder) => builder.person(person)) - .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) - .edit({ - action: AssetEditAction.Crop, - parameters: { - width: 1512, - height: 1152, - x: 216, - y: 1512, - }, - }) - .build(); - - const result = mapAsset(getForAsset(asset)); - - expect(result.people).toBeDefined(); - expect(result.people).toHaveLength(1); - expect(result.people![0].faces).toHaveLength(2); - - // Verify that both faces have been transformed (bounding boxes adjusted for crop) - const firstFace = result.people![0].faces[0]; - const secondFace = result.people![0].faces[1]; - - // After crop (x: 216, y: 1512), the coordinates should be adjusted - // Faces outside the crop area will be clamped - expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116 - expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412 - expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16 - expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312 - - expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216 - expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112 - expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216 - expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012 - }); - - it('should transform unassigned faces with edits and dimensions', () => { - const unassignedFace = AssetFaceFactory.create({ - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageWidth: 1000, - imageHeight: 800, - }); - - const asset = AssetFactory.from() - .face(unassignedFace) - .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) - .edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } }) - .build(); - - const result = mapAsset(getForAsset(asset)); - - expect(result.unassignedFaces).toBeDefined(); - expect(result.unassignedFaces).toHaveLength(1); - - // Verify that unassigned face has been transformed - const face = result.unassignedFaces![0]; - expect(face.boundingBoxX1).toBe(50); // 100 - 50 - expect(face.boundingBoxY1).toBe(50); // 100 - 50 - expect(face.boundingBoxX2).toBe(150); // 200 - 50 - expect(face.boundingBoxY2).toBe(150); // 200 - 50 - }); - - it('should handle multiple people each with multiple faces', () => { - const person1Face1 = { - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageWidth: 1000, - imageHeight: 800, - }; - - const person1Face2 = { - boundingBoxX1: 300, - boundingBoxY1: 300, - boundingBoxX2: 400, - boundingBoxY2: 400, - imageWidth: 1000, - imageHeight: 800, - }; - - const person2Face1 = { - boundingBoxX1: 500, - boundingBoxY1: 100, - boundingBoxX2: 600, - boundingBoxY2: 200, - imageWidth: 1000, - imageHeight: 800, - }; - - const person = PersonFactory.create({ id: 'person-1' }); - - const asset = AssetFactory.from() - .face(person1Face1, (builder) => builder.person(person)) - .face(person1Face2, (builder) => builder.person(person)) - .face(person2Face1, (builder) => builder.person({ id: 'person-2' })) - .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) - .build(); - - const result = mapAsset(getForAsset(asset)); - - expect(result.people).toBeDefined(); - expect(result.people).toHaveLength(2); - - const person1 = result.people!.find((p) => p.id === 'person-1'); - const person2 = result.people!.find((p) => p.id === 'person-2'); - - expect(person1).toBeDefined(); - expect(person1!.faces).toHaveLength(2); - // No edits, so coordinates should be unchanged - expect(person1!.faces[0].boundingBoxX1).toBe(100); - expect(person1!.faces[0].boundingBoxY1).toBe(100); - expect(person1!.faces[1].boundingBoxX1).toBe(300); - expect(person1!.faces[1].boundingBoxY1).toBe(300); - - expect(person2).toBeDefined(); - expect(person2!.faces).toHaveLength(1); - expect(person2!.faces[0].boundingBoxX1).toBe(500); - expect(person2!.faces[0].boundingBoxY1).toBe(100); - }); - - it('should combine faces of the same person into a single entry', () => { - const face1 = { - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageWidth: 1000, - imageHeight: 800, - }; - - const face2 = { - boundingBoxX1: 300, - boundingBoxY1: 300, - boundingBoxX2: 400, - boundingBoxY2: 400, - imageWidth: 1000, - imageHeight: 800, - }; - - const person = PersonFactory.create(); - - const asset = AssetFactory.from() - .face(face1, (builder) => builder.person(person)) - .face(face2, (builder) => builder.person(person)) - .exif({ exifImageWidth: 1000, exifImageHeight: 800 }) - .build(); - - const result = mapAsset(getForAsset(asset)); - - expect(result.people).toBeDefined(); - expect(result.people).toHaveLength(1); - - expect(result.people![0].id).toBe(person.id); - expect(result.people![0].faces).toHaveLength(2); - }); - }); -}); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 99d1fe7e25..6d72fd971a 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -5,13 +5,7 @@ import { HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { ExifResponseSchema, mapExif } from 'src/dtos/exif.dto'; -import { - AssetFaceWithoutPersonResponseSchema, - PersonWithFacesResponseDto, - PersonWithFacesResponseSchema, - mapFacesWithoutPerson, - mapPerson, -} from 'src/dtos/person.dto'; +import { PersonResponseDto, PersonResponseSchema, mapPerson } from 'src/dtos/person.dto'; import { TagResponseSchema, mapTag } from 'src/dtos/tag.dto'; import { UserResponseSchema, mapUser } from 'src/dtos/user.dto'; import { @@ -22,8 +16,7 @@ import { AssetVisibilitySchema, ChecksumAlgorithm, } from 'src/enum'; -import { ImageDimensions, MaybeDehydrated } from 'src/types'; -import { getDimensions } from 'src/utils/asset.util'; +import { MaybeDehydrated } from 'src/types'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { asDateString } from 'src/utils/date'; import { mimeTypes } from 'src/utils/mime-types'; @@ -107,8 +100,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend( visibility: AssetVisibilitySchema, exifInfo: ExifResponseSchema.optional(), tags: z.array(TagResponseSchema).optional(), - people: z.array(PersonWithFacesResponseSchema).optional(), - unassignedFaces: z.array(AssetFaceWithoutPersonResponseSchema).optional(), + people: z.array(PersonResponseSchema).optional(), checksum: z.string().describe('Base64 encoded SHA1 hash'), stack: AssetStackResponseSchema.nullish(), duplicateId: z.string().nullish().describe('Duplicate group ID'), @@ -170,33 +162,20 @@ export type AssetMapOptions = { auth?: AuthDto; }; -const peopleWithFaces = ( - faces?: MaybeDehydrated[], - edits?: AssetEditActionItem[], - assetDimensions?: ImageDimensions, -): PersonWithFacesResponseDto[] => { +const peopleFromFaces = (faces?: MaybeDehydrated[]): PersonResponseDto[] => { if (!faces) { return []; } - const peopleFaces: Map = new Map(); + const peopleMap: Map = new Map(); for (const face of faces) { - if (!face.person) { - continue; + if (face.person && !peopleMap.has(face.person.id)) { + peopleMap.set(face.person.id, mapPerson(face.person)); } - - if (!peopleFaces.has(face.person.id)) { - peopleFaces.set(face.person.id, { - ...mapPerson(face.person), - faces: [], - }); - } - const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions); - peopleFaces.get(face.person.id)!.faces.push(mappedFace); } - return [...peopleFaces.values()]; + return [...peopleMap.values()]; }; const mapStack = (entity: { stack?: Stack | null }) => { @@ -230,8 +209,6 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt return sanitizedAssetResponse as AssetResponseDto; } - const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined; - return { id: entity.id, createdAt: asDateString(entity.createdAt), @@ -255,10 +232,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), - people: peopleWithFaces(entity.faces, entity.edits, assetDimensions), - unassignedFaces: entity.faces - ?.filter((face) => !face.person) - .map((face) => mapFacesWithoutPerson(face, entity.edits, assetDimensions)), + people: peopleFromFaces(entity.faces), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 8cbbe6df78..f39cfd1c88 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -56,7 +56,7 @@ const PersonSearchSchema = z }) .meta({ id: 'PersonSearchDto' }); -const PersonResponseSchema = z +export const PersonResponseSchema = z .object({ id: z.string().describe('Person ID'), name: z.string().describe('Person name'), @@ -91,7 +91,7 @@ export class MergePersonDto extends createZodDto(MergePersonSchema) {} export class PersonSearchDto extends createZodDto(PersonSearchSchema) {} export class PersonResponseDto extends createZodDto(PersonResponseSchema) {} -export const AssetFaceWithoutPersonResponseSchema = z +export const AssetFaceResponseSchema = z .object({ id: z.uuidv4().describe('Face ID'), imageHeight: z.int().min(0).describe('Image height in pixels'), @@ -101,21 +101,10 @@ export const AssetFaceWithoutPersonResponseSchema = z boundingBoxY1: z.int().describe('Bounding box Y1 coordinate'), boundingBoxY2: z.int().describe('Bounding box Y2 coordinate'), sourceType: SourceTypeSchema.optional(), + person: PersonResponseSchema.nullable(), }) - .describe('Asset face without person') - .meta({ id: 'AssetFaceWithoutPersonResponseDto' }); - -class AssetFaceWithoutPersonResponseDto extends createZodDto(AssetFaceWithoutPersonResponseSchema) {} - -export const PersonWithFacesResponseSchema = PersonResponseSchema.extend({ - faces: z.array(AssetFaceWithoutPersonResponseSchema), -}).meta({ id: 'PersonWithFacesResponseDto' }); - -export class PersonWithFacesResponseDto extends createZodDto(PersonWithFacesResponseSchema) {} - -const AssetFaceResponseSchema = AssetFaceWithoutPersonResponseSchema.extend({ - person: PersonResponseSchema.nullable(), -}).meta({ id: 'AssetFaceResponseDto' }); + .describe('Asset face with person') + .meta({ id: 'AssetFaceResponseDto' }); export class AssetFaceResponseDto extends createZodDto(AssetFaceResponseSchema) {} @@ -193,11 +182,11 @@ export function mapPerson(person: MaybeDehydrated): PersonResponseDto { }; } -export function mapFacesWithoutPerson( +function mapFacesWithoutPerson( face: MaybeDehydrated>, edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, -): AssetFaceWithoutPersonResponseDto { +) { return { id: face.id, ...transformFaceBoundingBox( diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte index e2b53c8ef1..16f7c6cfb0 100644 --- a/web/src/lib/components/asset-viewer/AssetViewer.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte @@ -15,6 +15,7 @@ import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { getAssetActions } from '$lib/services/asset.service'; + import { faceManager } from '$lib/stores/face.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -333,6 +334,7 @@ case AssetAction.SET_PERSON_FEATURED_PHOTO: { const assetInfo = await getAssetInfo({ id: asset.id }); cursor.current = { ...asset, people: assetInfo.people }; + eventManager.emit('AssetUpdate', cursor.current); break; } case AssetAction.RATING: { @@ -376,11 +378,14 @@ const refresh = async () => { await refreshStack(); ocrManager.clear(); + faceManager.clear(); if (!sharedLink) { if (previewStackedAsset) { await ocrManager.getAssetOcr(previewStackedAsset.id); + await faceManager.getAssetFaces(previewStackedAsset.id); } await ocrManager.getAssetOcr(asset.id); + await faceManager.getAssetFaces(asset.id); } }; diff --git a/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte b/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte index 9a7c6215ec..78265f9ea2 100644 --- a/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte @@ -3,6 +3,7 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { Route } from '$lib/route'; + import { faceManager } from '$lib/stores/face.svelte'; import { locale } from '$lib/stores/preferences.store'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { type AssetResponseDto } from '@immich/sdk'; @@ -19,8 +20,7 @@ const { asset, isOwner, previousRoute }: Props = $props(); - const unassignedFaces = $derived(asset.unassignedFaces || []); - const people = $derived(asset.people || []); + const people = $derived(Array.from(faceManager.people)); const visiblePeople = $derived( people .filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden) @@ -82,7 +82,7 @@ onclick={() => assetViewerManager.toggleFaceEditMode()} /> - {#if people.length > 0 || unassignedFaces.length > 0} + {#if faceManager.data.length > 0} {#each visiblePeople as person (person.id)} - {@const isHighlighted = person.faces.some((f) => - assetViewerManager.highlightedFaces.some((b) => b.id === f.id), - )} + {@const personFaces = faceManager.facesByPersonId.get(person.id) ?? []} + {@const isHighlighted = personFaces.some((f) => assetViewerManager.highlightedFaces.some((b) => b.id === f.id))} assetViewerManager.setHighlightedFaces(person.faces)} + onfocus={() => assetViewerManager.setHighlightedFaces(personFaces)} onblur={() => assetViewerManager.clearHighlightedFaces()} - onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)} + onpointerenter={() => assetViewerManager.setHighlightedFaces(personFaces)} onpointerleave={() => assetViewerManager.clearHighlightedFaces()} > { // eslint-disable-next-line svelte/prefer-svelte-reactivity const map = new Map(); - for (const person of asset.people ?? []) { - if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) { + for (const face of faceManager.data) { + if (!face.person) { continue; } - for (const face of person.faces ?? []) { - map.set(face, person.name); + if (face.person.isHidden && !assetViewerManager.isShowingHiddenPeople) { + continue; } + map.set(face, face.person.name); } return map; }); diff --git a/web/src/lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte b/web/src/lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte index 0795662fd5..86e201c613 100644 --- a/web/src/lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte +++ b/web/src/lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte @@ -1,4 +1,5 @@