Compare commits

...

4 Commits

Author SHA1 Message Date
bwees
9be53cef8b chore: code review readonly 2026-04-16 10:57:01 -05:00
bwees
2ef0291923 chore: e2e generator 2026-04-15 23:49:44 -05:00
bwees
9c6859408b chore: tests 2026-04-15 22:10:28 -05:00
bwees
13828a3f8c refactor!: remove faces from people in AssetResposnseDto 2026-04-15 20:12:43 -05:00
21 changed files with 124 additions and 957 deletions

View File

@@ -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,

View File

@@ -337,7 +337,6 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,

View File

@@ -66,7 +66,6 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
livePhotoVideoId: null,
tags: [],
people: [],
unassignedFaces: [],
stack: undefined,
isOffline: false,
hasMetadata: true,

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)

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';

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':

View File

@@ -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',
};
}

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,
@@ -135,7 +134,7 @@ class AssetResponseDto {
/// Owner user ID
String ownerId;
List<PersonWithFacesResponseDto> people;
List<PersonResponseDto> people;
/// Is resized
///
@@ -155,8 +154,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;
@@ -198,7 +195,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;
@@ -235,13 +231,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>{};
@@ -314,7 +309,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) {
@@ -359,13 +353,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: json[r'width'] == null

View File

@@ -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',
};
}

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": [
@@ -16750,7 +16691,7 @@
},
"people": {
"items": {
"$ref": "#/components/schemas/PersonWithFacesResponseDto"
"$ref": "#/components/schemas/PersonResponseDto"
},
"type": "array"
},
@@ -16791,12 +16732,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",
@@ -19725,93 +19660,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": {

View File

@@ -789,29 +789,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 */
@@ -894,7 +876,7 @@ export type AssetResponseDto = {
owner?: UserResponseDto;
/** Owner user ID */
ownerId: string;
people?: PersonWithFacesResponseDto[];
people?: PersonResponseDto[];
/** Is resized */
resized?: boolean;
stack?: (AssetStackResponseDto) | null;
@@ -902,7 +884,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;
@@ -1138,24 +1119,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;
@@ -6909,11 +6872,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",
@@ -6935,6 +6893,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",

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);
});
});
});

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,

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(

View File

@@ -14,6 +14,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';
@@ -293,7 +294,7 @@
preAction?.(action);
};
const handleAction = async (action: Action) => {
const handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
case AssetAction.TRASH: {
@@ -313,8 +314,7 @@
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
cursor.current = { ...asset, people: assetInfo.people };
eventManager.emit('AssetFacesUpdated', asset.id);
break;
}
case AssetAction.RATING: {
@@ -358,11 +358,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);
}
};

View File

@@ -10,6 +10,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route';
import { faceManager } from '$lib/stores/face.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
@@ -56,8 +57,7 @@
let showEditFaces = $state(false);
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let people = $derived(faceManager.people || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
let dateTime = $derived(
@@ -185,7 +185,7 @@
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
{#if Array.from(people).some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
@@ -206,7 +206,7 @@
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
{#if faceManager.data.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
@@ -221,15 +221,16 @@
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#each people as person (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === person.id)}
{@const faces = faceManager.data.filter((face) => face.person?.id === person.id)}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onfocus={() => ($boundingBoxesArray = faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseover={() => ($boundingBoxesArray = faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">

View File

@@ -8,6 +8,7 @@
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } 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 { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -156,19 +157,6 @@
let adaptiveImage = $state<HTMLDivElement | undefined>();
const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const person of asset.people ?? []) {
for (const face of person.faces ?? []) {
map.set(face, person.name);
}
}
return map;
});
const faces = $derived(Array.from(faceToNameMap.keys()));
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
@@ -186,11 +174,11 @@
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
const faceBoxes = getBoundingBox(faceManager.data, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
$boundingBoxesArray.push(faceManager.data[index]);
}
}
};
@@ -265,17 +253,18 @@
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg>
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
{@const name = faceManager.faceNameMap.get(visibleBoundingBoxes[index])}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
{#if name}
<div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{faceToNameMap.get(visibleBoundingBoxes[index])}
{name}
</div>
{/if}
{/each}

View File

@@ -1,6 +1,6 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAssetInfo, getAssetOcr } from '@immich/sdk';
import { getAssetInfo, getAssetOcr, getFaces } from '@immich/sdk';
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
@@ -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({
@@ -47,6 +48,9 @@ class AssetCacheManager {
AssetUpdate: (asset) => {
this.invalidateAsset(asset.id);
},
AssetFacesUpdated: (assetId) => {
this.#faceCache.clearKey({ id: assetId });
},
});
}
@@ -58,10 +62,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 +81,14 @@ class AssetCacheManager {
this.#ocrCache.clear();
}
clearFaceCache() {
this.#faceCache.clear();
}
invalidate() {
this.clearAssetCache();
this.clearOcrCache();
this.clearFaceCache();
}
}

View File

@@ -36,6 +36,7 @@ export type Events = {
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AssetFacesUpdated: [string];
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumCreate: [AlbumResponseDto];

View File

@@ -0,0 +1,58 @@
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import type { Faces } from '$lib/stores/people.store';
import { CancellableTask } from '$lib/utils/cancellable-task';
import type { AssetFaceResponseDto, PersonResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
class FaceManager {
#data = $state<AssetFaceResponseDto[]>([]);
#faceLoader = new CancellableTask();
#cleared = false;
readonly faceNameMap = $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;
});
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();

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@immich/sdk', () => ({
getAssetInfo: vi.fn(),
getAssetOcr: vi.fn(),
getFaces: vi.fn(),
}));
const createMockOcrData = (overrides?: Partial<OcrBoundingBox>): OcrBoundingBox[] => [