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 <jason@rasm.me>
This commit is contained in:
Brandon Wees
2026-05-11 16:05:40 -05:00
committed by GitHub
parent a278c10c75
commit 38438c8d9a
21 changed files with 134 additions and 942 deletions
@@ -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,
@@ -338,7 +338,6 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,
@@ -66,7 +66,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
livePhotoVideoId: null,
tags: [],
people: [],
unassignedFaces: [],
stack: undefined,
isOffline: false,
hasMetadata: true,
-2
View File
@@ -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)
-2
View File
@@ -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';
-4
View File
@@ -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':
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return AssetFaceWithoutPersonResponseDto(
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
sourceType: SourceType.fromJson(json[r'sourceType']),
);
}
return null;
}
static List<AssetFaceWithoutPersonResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceWithoutPersonResponseDto>[];
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<String, AssetFaceWithoutPersonResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetFaceWithoutPersonResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<AssetFaceWithoutPersonResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceWithoutPersonResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'boundingBoxX1',
'boundingBoxX2',
'boundingBoxY1',
'boundingBoxY2',
'id',
'imageHeight',
'imageWidth',
};
}
+3 -10
View File
@@ -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<PersonWithFacesResponseDto> people;
List<PersonResponseDto> people;
/// Is resized
///
@@ -159,8 +158,6 @@ class AssetResponseDto {
AssetTypeEnum type;
List<AssetFaceWithoutPersonResponseDto> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -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<String>(json, r'originalPath')!,
owner: UserResponseDto.fromJson(json[r'owner']),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
people: PersonResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType<bool>(json, r'resized'),
stack: AssetStackResponseDto.fromJson(json[r'stack']),
tags: TagResponseDto.listFromJson(json[r'tags']),
thumbhash: mapValueOfType<String>(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<int>(json, r'width'),
@@ -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<AssetFaceWithoutPersonResponseDto> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return PersonWithFacesResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''),
);
}
return null;
}
static List<PersonWithFacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PersonWithFacesResponseDto>[];
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<String, PersonWithFacesResponseDto> mapFromJson(dynamic json) {
final map = <String, PersonWithFacesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<PersonWithFacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PersonWithFacesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'birthDate',
'faces',
'id',
'isHidden',
'name',
'thumbnailPath',
};
}
+2 -154
View File
@@ -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": {
+7 -44
View File
@@ -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",
-192
View File
@@ -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);
});
});
});
+9 -35
View File
@@ -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<AssetFace>[],
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): PersonWithFacesResponseDto[] => {
const peopleFromFaces = (faces?: MaybeDehydrated<AssetFace>[]): PersonResponseDto[] => {
if (!faces) {
return [];
}
const peopleFaces: Map<string, PersonWithFacesResponseDto> = new Map();
const peopleMap: Map<string, PersonResponseDto> = 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<MapAsset>, 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<MapAsset>, 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,
+7 -18
View File
@@ -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<Person>): PersonResponseDto {
};
}
export function mapFacesWithoutPerson(
function mapFacesWithoutPerson(
face: MaybeDehydrated<Selectable<AssetFaceTable>>,
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceWithoutPersonResponseDto {
) {
return {
id: face.id,
...transformFaceBoundingBox(
@@ -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);
}
};
@@ -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}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
@@ -98,15 +98,14 @@
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
{#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))}
<a
class="group outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
onfocus={() => assetViewerManager.setHighlightedFaces(personFaces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
onpointerenter={() => assetViewerManager.setHighlightedFaces(personFaces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<ImageThumbnail
@@ -8,6 +8,7 @@
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { faceManager } from '$lib/stores/face.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
@@ -157,13 +158,14 @@
const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
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;
});
@@ -1,4 +1,5 @@
<script lang="ts">
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
@@ -6,7 +7,6 @@
import { toastManager } from '@immich/ui';
import { mdiFaceManProfile } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
@@ -1,4 +1,4 @@
import { getAssetInfo, getAssetOcr } from '@immich/sdk';
import { getAssetInfo, getAssetOcr, getFaces } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
@@ -38,6 +38,7 @@ class AsyncCache<K, V> {
class AssetCacheManager {
#assetCache = new AsyncCache(getAssetInfo);
#ocrCache = new AsyncCache(getAssetOcr);
#faceCache = new AsyncCache(getFaces);
constructor() {
eventManager.on({
@@ -58,10 +59,15 @@ class AssetCacheManager {
return this.#ocrCache.getOrFetch({ id }, true);
}
async getAssetFaces(id: string) {
return this.#faceCache.getOrFetch({ id }, true);
}
invalidateAsset(id: string) {
const { key, slug } = authManager.params;
this.#assetCache.clearKey({ id, key, slug });
this.#ocrCache.clearKey({ id });
this.#faceCache.clearKey({ id });
}
clearAssetCache() {
@@ -72,9 +78,14 @@ class AssetCacheManager {
this.#ocrCache.clear();
}
clearFaceCache() {
this.#faceCache.clear();
}
invalidate() {
this.clearAssetCache();
this.clearOcrCache();
this.clearFaceCache();
}
}
+74
View File
@@ -0,0 +1,74 @@
import type { AssetFaceResponseDto, PersonResponseDto } from '@immich/sdk';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
class FaceManager {
#data = $state<AssetFaceResponseDto[]>([]);
#faceLoader = new CancellableTask();
#cleared = false;
readonly faceNames = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const face of this.data) {
if (!face.person) {
continue;
}
map.set(face, face.person.name);
}
return map;
});
readonly people = $derived.by(() => {
const people = new SvelteSet<PersonResponseDto>();
for (const face of this.data) {
if (face.person) {
people.add(face.person);
}
}
return people;
});
readonly facesByPersonId = $derived.by(() => {
const map = new SvelteMap<string, AssetFaceResponseDto[]>();
for (const face of faceManager.data) {
if (!face.person) {
continue;
}
const existing = map.get(face.person.id);
if (existing) {
existing.push(face);
} else {
map.set(face.person.id, [face]);
}
}
return map;
});
get data() {
return this.#data;
}
async getAssetFaces(id: string) {
if (this.#cleared) {
await this.#faceLoader.reset();
this.#cleared = false;
}
await this.#faceLoader.execute(async () => {
this.#data = await assetCacheManager.getAssetFaces(id);
}, false);
}
clear() {
this.#cleared = true;
this.#data = [];
}
}
export const faceManager = new FaceManager();
+1
View File
@@ -7,6 +7,7 @@ import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
vi.mock('@immich/sdk', () => ({
getAssetInfo: vi.fn(),
getAssetOcr: vi.fn(),
getFaces: vi.fn(),
}));
const createMockOcrData = (overrides?: Partial<OcrBoundingBox>): OcrBoundingBox[] => [