浏览代码

feat(web,server): unassign faces

martabal 1 年之前
父节点
当前提交
e23168810b
共有 32 个文件被更改,包括 1117 次插入167 次删除
  1. 107 2
      cli/src/api/open-api/api.ts
  2. 3 0
      mobile/openapi/.openapi-generator/FILES
  3. 2 0
      mobile/openapi/README.md
  4. 1 1
      mobile/openapi/doc/AssetResponseDto.md
  5. 56 0
      mobile/openapi/doc/FaceApi.md
  6. 16 0
      mobile/openapi/doc/PeopleWithFacesResponseDto.md
  7. 1 0
      mobile/openapi/lib/api.dart
  8. 48 0
      mobile/openapi/lib/api/face_api.dart
  9. 2 0
      mobile/openapi/lib/api_client.dart
  10. 8 4
      mobile/openapi/lib/model/asset_response_dto.dart
  11. 106 0
      mobile/openapi/lib/model/people_with_faces_response_dto.dart
  12. 1 1
      mobile/openapi/test/asset_response_dto_test.dart
  13. 5 0
      mobile/openapi/test/face_api_test.dart
  14. 32 0
      mobile/openapi/test/people_with_faces_response_dto_test.dart
  15. 64 4
      server/immich-openapi-specs.json
  16. 5 5
      server/src/domain/asset/response-dto/asset-response.dto.ts
  17. 6 0
      server/src/domain/person/person.dto.ts
  18. 15 0
      server/src/domain/person/person.service.spec.ts
  19. 15 0
      server/src/domain/person/person.service.ts
  20. 2 2
      server/src/domain/repositories/person.repository.ts
  21. 1 1
      server/src/immich/api-v1/asset/asset.service.ts
  22. 6 1
      server/src/immich/controllers/face.controller.ts
  23. 14 0
      server/test/fixtures/face.stub.ts
  24. 1 1
      server/test/fixtures/shared-link.stub.ts
  25. 107 2
      web/src/api/open-api/api.ts
  26. 55 52
      web/src/lib/components/asset-viewer/detail-panel.svelte
  27. 2 1
      web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
  28. 6 47
      web/src/lib/components/faces-page/assign-face-side-panel.svelte
  29. 263 42
      web/src/lib/components/faces-page/person-side-panel.svelte
  30. 120 0
      web/src/lib/components/faces-page/unassigned-faces-side-pannel.svelte
  31. 5 0
      web/src/lib/utils/people-utils.ts
  32. 42 1
      web/src/lib/utils/person.ts

+ 107 - 2
cli/src/api/open-api/api.ts

@@ -978,10 +978,10 @@ export interface AssetResponseDto {
     'ownerId': string;
     /**
      * 
-     * @type {Array<PersonWithFacesResponseDto>}
+     * @type {PeopleWithFacesResponseDto}
      * @memberof AssetResponseDto
      */
-    'people'?: Array<PersonWithFacesResponseDto>;
+    'people'?: PeopleWithFacesResponseDto | null;
     /**
      * 
      * @type {boolean}
@@ -2632,6 +2632,25 @@ export interface PeopleUpdateItem {
      */
     'name'?: string;
 }
+/**
+ * 
+ * @export
+ * @interface PeopleWithFacesResponseDto
+ */
+export interface PeopleWithFacesResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof PeopleWithFacesResponseDto
+     */
+    'numberOfAssets': number;
+    /**
+     * 
+     * @type {Array<PersonWithFacesResponseDto>}
+     * @memberof PeopleWithFacesResponseDto
+     */
+    'people': Array<PersonWithFacesResponseDto>;
+}
 /**
  * 
  * @export
@@ -11635,6 +11654,48 @@ export const FaceApiAxiosParamCreator = function (configuration?: Configuration)
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration)
 
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        unassignFace: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('unassignFace', 'id', id)
+            const localVarPath = `/face/{id}`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
             return {
                 url: toPathString(localVarUrlObj),
                 options: localVarRequestOptions,
@@ -11671,6 +11732,16 @@ export const FaceApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async unassignFace(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFaceResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.unassignFace(id, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
     }
 };
 
@@ -11699,6 +11770,15 @@ export const FaceApiFactory = function (configuration?: Configuration, basePath?
         reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
             return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFaceResponseDto> {
+            return localVarFp.unassignFace(requestParameters.id, options).then((request) => request(axios, basePath));
+        },
     };
 };
 
@@ -11737,6 +11817,20 @@ export interface FaceApiReassignFacesByIdRequest {
     readonly faceDto: FaceDto
 }
 
+/**
+ * Request parameters for unassignFace operation in FaceApi.
+ * @export
+ * @interface FaceApiUnassignFaceRequest
+ */
+export interface FaceApiUnassignFaceRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceApiUnassignFace
+     */
+    readonly id: string
+}
+
 /**
  * FaceApi - object-oriented interface
  * @export
@@ -11765,6 +11859,17 @@ export class FaceApi extends BaseAPI {
     public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) {
         return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath));
     }
+
+    /**
+     * 
+     * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof FaceApi
+     */
+    public unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig) {
+        return FaceApiFp(this.configuration).unassignFace(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
+    }
 }
 
 

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -102,6 +102,7 @@ doc/PathType.md
 doc/PeopleResponseDto.md
 doc/PeopleUpdateDto.md
 doc/PeopleUpdateItem.md
+doc/PeopleWithFacesResponseDto.md
 doc/PersonApi.md
 doc/PersonResponseDto.md
 doc/PersonStatisticsResponseDto.md
@@ -292,6 +293,7 @@ lib/model/path_type.dart
 lib/model/people_response_dto.dart
 lib/model/people_update_dto.dart
 lib/model/people_update_item.dart
+lib/model/people_with_faces_response_dto.dart
 lib/model/person_response_dto.dart
 lib/model/person_statistics_response_dto.dart
 lib/model/person_update_dto.dart
@@ -459,6 +461,7 @@ test/path_type_test.dart
 test/people_response_dto_test.dart
 test/people_update_dto_test.dart
 test/people_update_item_test.dart
+test/people_with_faces_response_dto_test.dart
 test/person_api_test.dart
 test/person_response_dto_test.dart
 test/person_statistics_response_dto_test.dart

+ 2 - 0
mobile/openapi/README.md

@@ -135,6 +135,7 @@ Class | Method | HTTP request | Description
 *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 *FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | 
 *FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | 
+*FaceApi* | [**unassignFace**](doc//FaceApi.md#unassignface) | **DELETE** /face/{id} | 
 *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | 
 *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} | 
 *LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library | 
@@ -299,6 +300,7 @@ Class | Method | HTTP request | Description
  - [PeopleResponseDto](doc//PeopleResponseDto.md)
  - [PeopleUpdateDto](doc//PeopleUpdateDto.md)
  - [PeopleUpdateItem](doc//PeopleUpdateItem.md)
+ - [PeopleWithFacesResponseDto](doc//PeopleWithFacesResponseDto.md)
  - [PersonResponseDto](doc//PersonResponseDto.md)
  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
  - [PersonUpdateDto](doc//PersonUpdateDto.md)

+ 1 - 1
mobile/openapi/doc/AssetResponseDto.md

@@ -30,7 +30,7 @@ Name | Type | Description | Notes
 **originalPath** | **String** |  | 
 **owner** | [**UserResponseDto**](UserResponseDto.md) |  | [optional] 
 **ownerId** | **String** |  | 
-**people** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) |  | [optional] [default to const []]
+**people** | [**PeopleWithFacesResponseDto**](PeopleWithFacesResponseDto.md) |  | [optional] 
 **resized** | **bool** |  | 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [optional] [default to const []]

+ 56 - 0
mobile/openapi/doc/FaceApi.md

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**getFaces**](FaceApi.md#getfaces) | **GET** /face | 
 [**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | 
+[**unassignFace**](FaceApi.md#unassignface) | **DELETE** /face/{id} | 
 
 
 # **getFaces**
@@ -125,3 +126,58 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
+# **unassignFace**
+> AssetFaceResponseDto unassignFace(id)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = FaceApi();
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+
+try {
+    final result = api_instance.unassignFace(id);
+    print(result);
+} catch (e) {
+    print('Exception when calling FaceApi->unassignFace: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+
+### Return type
+
+[**AssetFaceResponseDto**](AssetFaceResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+

+ 16 - 0
mobile/openapi/doc/PeopleWithFacesResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.PeopleWithFacesResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**numberOfAssets** | **int** |  | 
+**people** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -135,6 +135,7 @@ part 'model/path_type.dart';
 part 'model/people_response_dto.dart';
 part 'model/people_update_dto.dart';
 part 'model/people_update_item.dart';
+part 'model/people_with_faces_response_dto.dart';
 part 'model/person_response_dto.dart';
 part 'model/person_statistics_response_dto.dart';
 part 'model/person_update_dto.dart';

+ 48 - 0
mobile/openapi/lib/api/face_api.dart

@@ -119,4 +119,52 @@ class FaceApi {
     }
     return null;
   }
+
+  /// Performs an HTTP 'DELETE /face/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> unassignFaceWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/face/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<AssetFaceResponseDto?> unassignFace(String id,) async {
+    final response = await unassignFaceWithHttpInfo(id,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFaceResponseDto',) as AssetFaceResponseDto;
+    
+    }
+    return null;
+  }
 }

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -357,6 +357,8 @@ class ApiClient {
           return PeopleUpdateDto.fromJson(value);
         case 'PeopleUpdateItem':
           return PeopleUpdateItem.fromJson(value);
+        case 'PeopleWithFacesResponseDto':
+          return PeopleWithFacesResponseDto.fromJson(value);
         case 'PersonResponseDto':
           return PersonResponseDto.fromJson(value);
         case 'PersonStatisticsResponseDto':

+ 8 - 4
mobile/openapi/lib/model/asset_response_dto.dart

@@ -35,7 +35,7 @@ class AssetResponseDto {
     required this.originalPath,
     this.owner,
     required this.ownerId,
-    this.people = const [],
+    this.people,
     required this.resized,
     this.smartInfo,
     this.stack = const [],
@@ -104,7 +104,7 @@ class AssetResponseDto {
 
   String ownerId;
 
-  List<PersonWithFacesResponseDto> people;
+  PeopleWithFacesResponseDto? people;
 
   bool resized;
 
@@ -190,7 +190,7 @@ class AssetResponseDto {
     (originalPath.hashCode) +
     (owner == null ? 0 : owner!.hashCode) +
     (ownerId.hashCode) +
-    (people.hashCode) +
+    (people == null ? 0 : people!.hashCode) +
     (resized.hashCode) +
     (smartInfo == null ? 0 : smartInfo!.hashCode) +
     (stack.hashCode) +
@@ -240,7 +240,11 @@ class AssetResponseDto {
     //  json[r'owner'] = null;
     }
       json[r'ownerId'] = this.ownerId;
+    if (this.people != null) {
       json[r'people'] = this.people;
+    } else {
+    //  json[r'people'] = null;
+    }
       json[r'resized'] = this.resized;
     if (this.smartInfo != null) {
       json[r'smartInfo'] = this.smartInfo;
@@ -299,7 +303,7 @@ 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: PeopleWithFacesResponseDto.fromJson(json[r'people']),
         resized: mapValueOfType<bool>(json, r'resized')!,
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
         stack: AssetResponseDto.listFromJson(json[r'stack']),

+ 106 - 0
mobile/openapi/lib/model/people_with_faces_response_dto.dart

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

+ 1 - 1
mobile/openapi/test/asset_response_dto_test.dart

@@ -127,7 +127,7 @@ void main() {
       // TODO
     });
 
-    // List<PersonWithFacesResponseDto> people (default value: const [])
+    // PeopleWithFacesResponseDto people
     test('to test the property `people`', () async {
       // TODO
     });

+ 5 - 0
mobile/openapi/test/face_api_test.dart

@@ -27,5 +27,10 @@ void main() {
       // TODO
     });
 
+    //Future<AssetFaceResponseDto> unassignFace(String id) async
+    test('test unassignFace', () async {
+      // TODO
+    });
+
   });
 }

+ 32 - 0
mobile/openapi/test/people_with_faces_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// 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
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for PeopleWithFacesResponseDto
+void main() {
+  // final instance = PeopleWithFacesResponseDto();
+
+  group('test PeopleWithFacesResponseDto', () {
+    // int numberOfAssets
+    test('to test the property `numberOfAssets`', () async {
+      // TODO
+    });
+
+    // List<PersonWithFacesResponseDto> people (default value: const [])
+    test('to test the property `people`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 64 - 4
server/immich-openapi-specs.json

@@ -3266,6 +3266,46 @@
       }
     },
     "/face/{id}": {
+      "delete": {
+        "operationId": "unassignFace",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/AssetFaceResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Face"
+        ]
+      },
       "put": {
         "operationId": "reassignFacesById",
         "parameters": [
@@ -7012,10 +7052,12 @@
             "type": "string"
           },
           "people": {
-            "items": {
-              "$ref": "#/components/schemas/PersonWithFacesResponseDto"
-            },
-            "type": "array"
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/PeopleWithFacesResponseDto"
+              }
+            ],
+            "nullable": true
           },
           "resized": {
             "type": "boolean"
@@ -8390,6 +8432,24 @@
         ],
         "type": "object"
       },
+      "PeopleWithFacesResponseDto": {
+        "properties": {
+          "numberOfAssets": {
+            "type": "integer"
+          },
+          "people": {
+            "items": {
+              "$ref": "#/components/schemas/PersonWithFacesResponseDto"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "numberOfAssets",
+          "people"
+        ],
+        "type": "object"
+      },
       "PersonResponseDto": {
         "properties": {
           "birthDate": {

+ 5 - 5
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -1,6 +1,6 @@
 import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
-import { PersonWithFacesResponseDto } from '../../person/person.dto';
+import { PeopleWithFacesResponseDto, PersonWithFacesResponseDto } from '../../person/person.dto';
 import { TagResponseDto, mapTag } from '../../tag';
 import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
 import { ExifResponseDto, mapExif } from './exif-response.dto';
@@ -39,7 +39,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
   exifInfo?: ExifResponseDto;
   smartInfo?: SmartInfoResponseDto;
   tags?: TagResponseDto[];
-  people?: PersonWithFacesResponseDto[];
+  people?: PeopleWithFacesResponseDto | null;
   /**base64 encoded sha1 hash */
   checksum!: string;
   stackParentId?: string | null;
@@ -53,7 +53,7 @@ export type AssetMapOptions = {
   withStack?: boolean;
 };
 
-const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
+const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto => {
   const result: PersonWithFacesResponseDto[] = [];
   if (faces) {
     faces.forEach((face) => {
@@ -68,7 +68,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
     });
   }
 
-  return result;
+  return { people: result, numberOfAssets: faces.length };
 };
 
 export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
@@ -114,7 +114,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
     tags: entity.tags?.map(mapTag),
-    people: peopleWithFaces(entity.faces),
+    people: entity.faces ? peopleWithFaces(entity.faces) : null,
     checksum: entity.checksum.toString('base64'),
     stackParentId: entity.stackParentId,
     stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,

+ 6 - 0
server/src/domain/person/person.dto.ts

@@ -78,6 +78,12 @@ export class PersonWithFacesResponseDto extends PersonResponseDto {
   faces!: AssetFaceWithoutPersonResponseDto[];
 }
 
+export class PeopleWithFacesResponseDto {
+  people!: PersonWithFacesResponseDto[];
+  @ApiProperty({ type: 'integer' })
+  numberOfAssets!: number;
+}
+
 export class AssetFaceWithoutPersonResponseDto {
   @ValidateUUID()
   id!: string;

+ 15 - 0
server/src/domain/person/person.service.spec.ts

@@ -491,6 +491,21 @@ describe(PersonService.name, () => {
     });
   });
 
+  describe('unassignFace', () => {
+    it('should unassign a face', async () => {
+      personMock.getFaceById.mockResolvedValue(faceStub.face1);
+      accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
+      accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
+      personMock.reassignFace.mockResolvedValue(1);
+      personMock.getRandomFace.mockResolvedValue(null);
+      personMock.getFaceById.mockResolvedValue(faceStub.unassignedFace);
+
+      await expect(sut.unassignFace(authStub.admin, faceStub.face1.id)).resolves.toStrictEqual(
+        mapFaces(faceStub.unassignedFace, authStub.admin),
+      );
+    });
+  });
+
   describe('handlePersonDelete', () => {
     it('should stop if a person has not be found', async () => {
       personMock.getById.mockResolvedValue(null);

+ 15 - 0
server/src/domain/person/person.service.ts

@@ -117,6 +117,21 @@ export class PersonService {
     return result;
   }
 
+  async unassignFace(authUser: AuthUserDto, id: string): Promise<AssetFaceResponseDto> {
+    let face = await this.repository.getFaceById(id);
+    await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id);
+    if (face.personId) {
+      await this.access.requirePermission(authUser, Permission.PERSON_WRITE, face.personId);
+    }
+
+    await this.repository.reassignFace(face.id, null);
+    if (face.person && face.person.faceAssetId === face.id) {
+      await this.createNewFeaturePhoto([face.person.id]);
+    }
+    face = await this.repository.getFaceById(id);
+    return mapFaces(face, authUser);
+  }
+
   async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
     await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
 

+ 2 - 2
server/src/domain/repositories/person.repository.ts

@@ -18,7 +18,7 @@ export interface AssetFaceId {
 
 export interface UpdateFacesData {
   oldPersonId: string;
-  newPersonId: string;
+  newPersonId: string | null;
 }
 
 export interface PersonStatistics {
@@ -49,7 +49,7 @@ export interface IPersonRepository {
   getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
   createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
   getFaces(assetId: string): Promise<AssetFaceEntity[]>;
-  reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
+  reassignFace(assetFaceId: string, newPersonId: string | null): Promise<number>;
   getFaceById(id: string): Promise<AssetFaceEntity>;
   getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null>;
 }

+ 1 - 1
server/src/immich/api-v1/asset/asset.service.ts

@@ -133,7 +133,7 @@ export class AssetService {
       const data = mapAsset(asset, { withStack: true });
 
       if (data.ownerId !== authUser.id) {
-        data.people = [];
+        data.people = null;
       }
 
       if (authUser.isPublicUser) {

+ 6 - 1
server/src/immich/controllers/face.controller.ts

@@ -1,5 +1,5 @@
 import { AssetFaceResponseDto, AuthUserDto, FaceDto, PersonResponseDto, PersonService } from '@app/domain';
-import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { AuthUser, Authenticated } from '../app.guard';
 import { UseValidation } from '../app.utils';
@@ -25,4 +25,9 @@ export class FaceController {
   ): Promise<PersonResponseDto> {
     return this.service.reassignFacesById(authUser, id, dto);
   }
+
+  @Delete(':id')
+  unassignFace(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetFaceResponseDto> {
+    return this.service.unassignFace(authUser, id);
+  }
 }

+ 14 - 0
server/test/fixtures/face.stub.ts

@@ -17,6 +17,20 @@ export const faceStub = {
     imageHeight: 1024,
     imageWidth: 1024,
   }),
+  unassignedFace: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
+    assetId: assetStub.image.id,
+    asset: assetStub.image,
+    personId: null,
+    person: null,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
   primaryFace1: Object.freeze<AssetFaceEntity>({
     id: 'assetFaceId',
     assetId: assetStub.image.id,

+ 1 - 1
server/test/fixtures/shared-link.stub.ts

@@ -67,7 +67,7 @@ const assetResponse: AssetResponseDto = {
   exifInfo: assetInfo,
   livePhotoVideoId: null,
   tags: [],
-  people: [],
+  people: null,
   checksum: 'ZmlsZSBoYXNo',
   isTrashed: false,
   libraryId: 'library-id',

+ 107 - 2
web/src/api/open-api/api.ts

@@ -978,10 +978,10 @@ export interface AssetResponseDto {
     'ownerId': string;
     /**
      * 
-     * @type {Array<PersonWithFacesResponseDto>}
+     * @type {PeopleWithFacesResponseDto}
      * @memberof AssetResponseDto
      */
-    'people'?: Array<PersonWithFacesResponseDto>;
+    'people'?: PeopleWithFacesResponseDto | null;
     /**
      * 
      * @type {boolean}
@@ -2632,6 +2632,25 @@ export interface PeopleUpdateItem {
      */
     'name'?: string;
 }
+/**
+ * 
+ * @export
+ * @interface PeopleWithFacesResponseDto
+ */
+export interface PeopleWithFacesResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof PeopleWithFacesResponseDto
+     */
+    'numberOfAssets': number;
+    /**
+     * 
+     * @type {Array<PersonWithFacesResponseDto>}
+     * @memberof PeopleWithFacesResponseDto
+     */
+    'people': Array<PersonWithFacesResponseDto>;
+}
 /**
  * 
  * @export
@@ -11635,6 +11654,48 @@ export const FaceApiAxiosParamCreator = function (configuration?: Configuration)
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration)
 
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        unassignFace: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('unassignFace', 'id', id)
+            const localVarPath = `/face/{id}`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
             return {
                 url: toPathString(localVarUrlObj),
                 options: localVarRequestOptions,
@@ -11671,6 +11732,16 @@ export const FaceApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async unassignFace(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFaceResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.unassignFace(id, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
     }
 };
 
@@ -11699,6 +11770,15 @@ export const FaceApiFactory = function (configuration?: Configuration, basePath?
         reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
             return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig): AxiosPromise<AssetFaceResponseDto> {
+            return localVarFp.unassignFace(requestParameters.id, options).then((request) => request(axios, basePath));
+        },
     };
 };
 
@@ -11737,6 +11817,20 @@ export interface FaceApiReassignFacesByIdRequest {
     readonly faceDto: FaceDto
 }
 
+/**
+ * Request parameters for unassignFace operation in FaceApi.
+ * @export
+ * @interface FaceApiUnassignFaceRequest
+ */
+export interface FaceApiUnassignFaceRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceApiUnassignFace
+     */
+    readonly id: string
+}
+
 /**
  * FaceApi - object-oriented interface
  * @export
@@ -11765,6 +11859,17 @@ export class FaceApi extends BaseAPI {
     public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) {
         return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath));
     }
+
+    /**
+     * 
+     * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof FaceApi
+     */
+    public unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig) {
+        return FaceApiFp(this.configuration).unassignFace(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
+    }
 }
 
 

+ 55 - 52
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -59,7 +59,7 @@
     // Get latest description from server
     if (asset.id && !api.isSharedLink) {
       api.assetApi.getAssetById({ id: asset.id }).then((res) => {
-        people = res.data?.people || [];
+        people = res.data?.people?.people || [];
         textarea.value = res.data?.exifInfo?.description || '';
       });
     }
@@ -74,7 +74,8 @@
     }
   })();
 
-  $: people = asset.people || [];
+  $: people = asset.people?.people || [];
+  $: numberOfFaces = asset.people?.numberOfAssets || 0;
   $: showingHiddenPeople = false;
 
   const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => {
@@ -101,7 +102,8 @@
 
   const handleRefreshPeople = async () => {
     await api.assetApi.getAssetById({ id: asset.id }).then((res) => {
-      people = res.data?.people || [];
+      people = res.data?.people?.people || [];
+      numberOfFaces = asset.people?.numberOfAssets || 0;
       textarea.value = res.data?.exifInfo?.description || '';
     });
     showEditFaces = false;
@@ -201,7 +203,7 @@
     />
   </section>
 
-  {#if !api.isSharedLink && people.length > 0}
+  {#if !api.isSharedLink && numberOfFaces > 0}
     <section class="px-4 py-4 text-sm">
       <div class="flex h-10 w-full items-center justify-between">
         <h2>PEOPLE</h2>
@@ -223,55 +225,56 @@
           />
         </div>
       </div>
-
-      <div class="mt-2 flex flex-wrap gap-2">
-        {#each people as person, index (person.id)}
-          <div
-            role="button"
-            tabindex={index}
-            on:focus={() => ($boundingBoxesArray = people[index].faces)}
-            on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
-            on:mouseleave={() => ($boundingBoxesArray = [])}
-          >
-            <a
-              href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
-              class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
-              on:click={() => dispatch('close-viewer')}
+      {#if people.length > 0}
+        <div class="mt-2 flex flex-wrap gap-2">
+          {#each people as person, index (person.id)}
+            <div
+              role="button"
+              tabindex={index}
+              on:focus={() => ($boundingBoxesArray = people[index].faces)}
+              on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
+              on:mouseleave={() => ($boundingBoxesArray = [])}
             >
-              <div class="relative">
-                <ImageThumbnail
-                  curve
-                  shadow
-                  url={api.getPeopleThumbnailUrl(person.id)}
-                  altText={person.name}
-                  title={person.name}
-                  widthStyle="90px"
-                  heightStyle="90px"
-                  thumbhash={null}
-                  hidden={person.isHidden}
-                />
-              </div>
-              <p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
-              {#if person.birthDate}
-                {@const personBirthDate = DateTime.fromISO(person.birthDate)}
-                <p
-                  class="font-light"
-                  title={personBirthDate.toLocaleString(
-                    {
-                      month: 'long',
-                      day: 'numeric',
-                      year: 'numeric',
-                    },
-                    { locale: $locale },
-                  )}
-                >
-                  Age {Math.floor(DateTime.fromISO(asset.fileCreatedAt).diff(personBirthDate, 'years').years)}
-                </p>
-              {/if}
-            </a>
-          </div>
-        {/each}
-      </div>
+              <a
+                href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
+                class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
+                on:click={() => dispatch('close-viewer')}
+              >
+                <div class="relative">
+                  <ImageThumbnail
+                    curve
+                    shadow
+                    url={api.getPeopleThumbnailUrl(person.id)}
+                    altText={person.name}
+                    title={person.name}
+                    widthStyle="90px"
+                    heightStyle="90px"
+                    thumbhash={null}
+                    hidden={person.isHidden}
+                  />
+                </div>
+                <p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
+                {#if person.birthDate}
+                  {@const personBirthDate = DateTime.fromISO(person.birthDate)}
+                  <p
+                    class="font-light"
+                    title={personBirthDate.toLocaleString(
+                      {
+                        month: 'long',
+                        day: 'numeric',
+                        year: 'numeric',
+                      },
+                      { locale: $locale },
+                    )}
+                  >
+                    Age {Math.floor(DateTime.fromISO(asset.fileCreatedAt).diff(personBirthDate, 'years').years)}
+                  </p>
+                {/if}
+              </a>
+            </div>
+          {/each}
+        </div>
+      {/if}
     </section>
   {/if}
 

+ 2 - 1
web/src/lib/components/assets/thumbnail/image-thumbnail.svelte

@@ -19,6 +19,7 @@
   export let border = false;
   export let preload = true;
   export let eyeColor: 'black' | 'white' = 'white';
+  export let persistentBorder = false;
 
   let complete = false;
   let img: HTMLImageElement;
@@ -42,7 +43,7 @@
   {title}
   class="object-cover transition duration-300 {border
     ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
-    : ''}"
+    : ''} {persistentBorder ? 'border-[3px] border-immich-dark-primary/80 border-immich-primary' : ''}"
   class:rounded-xl={curve}
   class:shadow-lg={shadow}
   class:rounded-full={circle}

+ 6 - 47
web/src/lib/components/faces-page/assign-face-side-panel.svelte

@@ -7,13 +7,12 @@
   import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
   import LoadingSpinner from '../shared-components/loading-spinner.svelte';
   import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
-  import { getPersonNameWithHiddenValue, searchNameLocal } from '$lib/utils/person';
+  import { getPersonNameWithHiddenValue, searchNameLocal, zoomImageToBase64 } from '$lib/utils/person';
   import { handleError } from '$lib/utils/handle-error';
   import { photoViewer } from '$lib/stores/assets.store';
 
-  export let peopleWithFaces: AssetFaceResponseDto[];
+  export let personWithFace: AssetFaceResponseDto;
   export let allPeople: PersonResponseDto[];
-  export let editedPersonIndex: number;
 
   // loading spinners
   let isShowLoadingNewPerson = false;
@@ -30,51 +29,11 @@
   const handleBackButton = () => {
     dispatch('close');
   };
-  const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise<string | null> => {
-    if ($photoViewer === null) {
-      return null;
-    }
-    const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2 } = face;
-
-    const coordinates = {
-      x1: ($photoViewer.naturalWidth / face.imageWidth) * x1,
-      x2: ($photoViewer.naturalWidth / face.imageWidth) * x2,
-      y1: ($photoViewer.naturalHeight / face.imageHeight) * y1,
-      y2: ($photoViewer.naturalHeight / face.imageHeight) * y2,
-    };
-
-    const faceWidth = coordinates.x2 - coordinates.x1;
-    const faceHeight = coordinates.y2 - coordinates.y1;
-
-    const faceImage = new Image();
-    faceImage.src = $photoViewer.src;
-
-    await new Promise((resolve) => {
-      faceImage.onload = resolve;
-      faceImage.onerror = () => resolve(null);
-    });
-
-    const canvas = document.createElement('canvas');
-    canvas.width = faceWidth;
-    canvas.height = faceHeight;
-
-    const ctx = canvas.getContext('2d');
-    if (ctx) {
-      ctx.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
-
-      return canvas.toDataURL();
-    } else {
-      return null;
-    }
-  };
 
   const handleCreatePerson = async () => {
     const timeout = setTimeout(() => (isShowLoadingNewPerson = true), 100);
-    const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
-
-    const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
 
-    dispatch('createPerson', newFeaturePhoto);
+    const newFeaturePhoto = await zoomImageToBase64(personWithFace, $photoViewer);
 
     clearTimeout(timeout);
     isShowLoadingNewPerson = false;
@@ -111,7 +70,7 @@
 
 <section
   transition:fly={{ x: 360, duration: 100, easing: linear }}
-  class="absolute top-0 z-[2001] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
+  class="absolute top-0 z-[2002] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
 >
   <div class="flex place-items-center justify-between gap-2">
     {#if !searchFaces}
@@ -193,7 +152,7 @@
     <div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
       {#if searchName == ''}
         {#each allPeople as person (person.id)}
-          {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
+          {#if person.id !== personWithFace.person?.id}
             <div class="w-fit">
               <button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
                 <div class="relative">
@@ -219,7 +178,7 @@
         {/each}
       {:else}
         {#each searchedPeople as person (person.id)}
-          {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
+          {#if person.id !== personWithFace.person?.id}
             <div class="w-fit">
               <button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
                 <div class="relative">

+ 263 - 42
web/src/lib/components/faces-page/person-side-panel.svelte

@@ -7,31 +7,40 @@
   import { createEventDispatcher, onMount } from 'svelte';
   import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
   import { NotificationType, notificationController } from '../shared-components/notification/notification';
-  import { mdiArrowLeftThin, mdiRestart } from '@mdi/js';
+  import { mdiAccountOff, mdiArrowLeftThin, mdiFaceMan, mdiRestart, mdiSelect } from '@mdi/js';
   import Icon from '../elements/icon.svelte';
   import { boundingBoxesArray } from '$lib/stores/people.store';
   import { websocketStore } from '$lib/stores/websocket';
   import AssignFaceSidePanel from './assign-face-side-panel.svelte';
-  import { getPersonNameWithHiddenValue } from '$lib/utils/person';
+  import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
+  import { photoViewer } from '$lib/stores/assets.store';
+  import UnassignedFacesSidePannel from './unassigned-faces-side-pannel.svelte';
+  import type { FaceWithGeneretedThumbnail } from '$lib/utils/people-utils';
 
   export let assetId: string;
 
   // keep track of the changes
-  let numberOfPersonToCreate: string[] = [];
-  let numberOfAssetFaceGenerated: string[] = [];
+  let idsOfPersonToCreate: string[] = [];
+  let idsOfAssetFaceGenerated: string[] = [];
 
   // faces
   let peopleWithFaces: AssetFaceResponseDto[] = [];
   let selectedPersonToReassign: (PersonResponseDto | null)[];
   let selectedPersonToCreate: (string | null)[];
+  let selectedPersonToAdd: FaceWithGeneretedThumbnail[] = [];
+  let selectedPersonToRemove: boolean[] = [];
+  let unassignedFaces: (FaceWithGeneretedThumbnail | null)[] = [];
   let editedPersonIndex: number;
+  let shouldRefresh: boolean = false;
 
   // loading spinners
   let isShowLoadingDone = false;
   let isShowLoadingPeople = false;
 
-  // search people
+  // other modals
   let showSeletecFaces = false;
+  let showUnassignedFaces = false;
+  let isSelectingFaces = false;
   let allPeople: PersonResponseDto[] = [];
 
   // timers
@@ -43,15 +52,17 @@
 
   // Reset value
   $onPersonThumbnail = '';
-
+  $: numberOfFacesToUnassign = selectedPersonToRemove ? selectedPersonToRemove.filter((value) => value).length : 0;
   $: {
     if ($onPersonThumbnail) {
-      numberOfAssetFaceGenerated.push($onPersonThumbnail);
+      idsOfAssetFaceGenerated.push($onPersonThumbnail);
       if (
-        isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
+        isEqual(idsOfAssetFaceGenerated, idsOfPersonToCreate) &&
         loaderLoadingDoneTimeout &&
         automaticRefreshTimeout &&
-        selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
+        selectedPersonToCreate.filter((person) => person !== null).length +
+          selectedPersonToAdd.filter((face) => face.person === null).length ===
+          idsOfPersonToCreate.length
       ) {
         clearTimeout(loaderLoadingDoneTimeout);
         clearTimeout(automaticRefreshTimeout);
@@ -69,6 +80,17 @@
       peopleWithFaces = result.data;
       selectedPersonToCreate = new Array<string | null>(peopleWithFaces.length);
       selectedPersonToReassign = new Array<PersonResponseDto | null>(peopleWithFaces.length);
+      selectedPersonToRemove = new Array<boolean>(peopleWithFaces.length);
+      unassignedFaces = await Promise.all(
+        peopleWithFaces.map(async (personWithFace) => {
+          if (personWithFace.person) {
+            return null;
+          } else {
+            const image = await zoomImageToBase64(personWithFace, $photoViewer);
+            return image ? { ...personWithFace, customThumbnail: image } : null;
+          }
+        }),
+      );
     } catch (error) {
       handleError(error, "Can't get faces");
     } finally {
@@ -82,6 +104,15 @@
   };
 
   const handleBackButton = () => {
+    if (isSelectingFaces) {
+      isSelectingFaces = false;
+      selectedPersonToRemove = new Array<boolean>(peopleWithFaces.length);
+      return;
+    }
+    if (shouldRefresh) {
+      dispatch('refresh');
+      return;
+    }
     dispatch('close');
   };
 
@@ -94,11 +125,60 @@
     }
   };
 
+  const handleOpenAvailableFaces = () => {
+    showUnassignedFaces = true;
+  };
+
+  const handleSelectFaces = () => {
+    isSelectingFaces = !isSelectingFaces;
+  };
+
+  const handleSelectFace = (index: number) => {
+    if (!isSelectingFaces) {
+      return;
+    }
+    selectedPersonToRemove[index] = !selectedPersonToRemove[index];
+  };
+
+  const handleRemoveAddedFace = (indexToRemove: number) => {
+    $boundingBoxesArray = [];
+    selectedPersonToAdd = selectedPersonToAdd.filter((_, index) => index !== indexToRemove);
+  };
+
+  const handleUnassignFaces = async () => {
+    if (numberOfFacesToUnassign > 0) {
+      try {
+        for (let i = 0; i < peopleWithFaces.length; i++) {
+          if (selectedPersonToRemove[i]) {
+            await api.faceApi.unassignFace({
+              id: peopleWithFaces[i].id,
+            });
+            shouldRefresh = true;
+            peopleWithFaces[i].person = null;
+            const image = await zoomImageToBase64(peopleWithFaces[i], $photoViewer);
+            if (image) {
+              unassignedFaces[i] = { ...peopleWithFaces[i], customThumbnail: image };
+            }
+          }
+        }
+
+        notificationController.show({
+          message: `Unassigned ${numberOfFacesToUnassign} face${numberOfFacesToUnassign > 1 ? 's' : ''}`,
+          type: NotificationType.Info,
+        });
+      } catch (error) {
+        handleError(error, "Can't apply changes");
+      }
+    }
+    isSelectingFaces = false;
+  };
+
   const handleEditFaces = async () => {
     loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), 100);
     const numberOfChanges =
       selectedPersonToCreate.filter((person) => person !== null).length +
-      selectedPersonToReassign.filter((person) => person !== null).length;
+      selectedPersonToReassign.filter((person) => person !== null).length +
+      selectedPersonToAdd.length;
     if (numberOfChanges > 0) {
       try {
         for (let i = 0; i < peopleWithFaces.length; i++) {
@@ -111,13 +191,28 @@
             });
           } else if (selectedPersonToCreate[i]) {
             const { data } = await api.personApi.createPerson();
-            numberOfPersonToCreate.push(data.id);
+            idsOfPersonToCreate.push(data.id);
             await api.faceApi.reassignFacesById({
               id: data.id,
               faceDto: { id: peopleWithFaces[i].id },
             });
           }
         }
+        for (const face of selectedPersonToAdd) {
+          if (face.person) {
+            await api.faceApi.reassignFacesById({
+              id: face.person.id,
+              faceDto: { id: face.id },
+            });
+          } else {
+            const { data } = await api.personApi.createPerson();
+            idsOfPersonToCreate.push(data.id);
+            await api.faceApi.reassignFacesById({
+              id: data.id,
+              faceDto: { id: face.id },
+            });
+          }
+        }
 
         notificationController.show({
           message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
@@ -129,7 +224,7 @@
     }
 
     isShowLoadingDone = false;
-    if (numberOfPersonToCreate.length === 0) {
+    if (idsOfPersonToCreate.length === 0) {
       clearTimeout(loaderLoadingDoneTimeout);
       dispatch('refresh');
     } else {
@@ -148,8 +243,20 @@
   const handleReassignFace = (person: PersonResponseDto | null) => {
     if (person) {
       selectedPersonToReassign[editedPersonIndex] = person;
-      showSeletecFaces = false;
     }
+    showSeletecFaces = false;
+  };
+
+  const handleCreatePersonFromUnassignedFace = (face: FaceWithGeneretedThumbnail) => {
+    selectedPersonToAdd.push(face);
+    selectedPersonToAdd = selectedPersonToAdd;
+    showUnassignedFaces = false;
+  };
+
+  const handleReassignFaceFromUnassignedFace = (face: FaceWithGeneretedThumbnail) => {
+    selectedPersonToAdd.push(face);
+    selectedPersonToAdd = selectedPersonToAdd;
+    showUnassignedFaces = false;
   };
 
   const handlePersonPicker = async (index: number) => {
@@ -172,21 +279,70 @@
           <Icon path={mdiArrowLeftThin} size="24" />
         </div>
       </button>
-      <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
+      <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">
+        {isSelectingFaces ? 'Select Faces' : 'Edit faces'}
+      </p>
     </div>
     {#if !isShowLoadingDone}
-      <button
-        class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
-        on:click={() => handleEditFaces()}
-      >
-        Done
-      </button>
+      <div class="flex items-center gap-2">
+        {#if !isSelectingFaces && unassignedFaces.length > 0}
+          <button
+            class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
+            on:click={handleOpenAvailableFaces}
+            title="Faces available"
+          >
+            <div>
+              <Icon path={mdiFaceMan} />
+            </div>
+          </button>
+        {/if}
+        {#if !peopleWithFaces.every((item) => item.person === null)}
+          <button
+            class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
+            on:click={handleSelectFaces}
+            title="Select faces to unassign"
+          >
+            <div>
+              <Icon path={mdiSelect} />
+            </div>
+          </button>
+        {/if}
+        {#if !isSelectingFaces}
+          <button
+            class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
+            on:click={handleEditFaces}
+          >
+            Done
+          </button>
+        {/if}
+      </div>
     {:else}
       <LoadingSpinner />
     {/if}
   </div>
 
   <div class="px-4 py-4 text-sm">
+    <div class="flex items-center justify-between gap-2">
+      {#if peopleWithFaces.every((item) => item.person === null)}
+        <div class="flex items-center justify-center w-full">
+          <div class="grid place-items-center">
+            <Icon path={mdiAccountOff} size="3.5em" />
+            <p class="mt-5 font-medium">No faces visible</p>
+          </div>
+        </div>
+      {:else}
+        <div>Visible faces</div>
+      {/if}
+
+      {#if isSelectingFaces && selectedPersonToRemove && selectedPersonToRemove.filter((value) => value).length > 0}
+        <button
+          class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
+          on:click={handleUnassignFaces}
+        >
+          Unassign faces
+        </button>
+      {/if}
+    </div>
     <div class="mt-4 flex flex-wrap gap-2">
       {#if isShowLoadingPeople}
         <div class="flex w-full justify-center">
@@ -203,6 +359,8 @@
                 on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
                 on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
                 on:mouseleave={() => ($boundingBoxesArray = [])}
+                on:click={() => handleSelectFace(index)}
+                on:keydown={() => handleSelectFace(index)}
               >
                 <div class="relative">
                   <ImageThumbnail
@@ -223,11 +381,14 @@
                     widthStyle="90px"
                     heightStyle="90px"
                     thumbhash={null}
-                    hidden={selectedPersonToReassign[index]
-                      ? selectedPersonToReassign[index]?.isHidden
-                      : selectedPersonToCreate[index]
-                        ? false
-                        : face.person?.isHidden}
+                    hidden={!isSelectingFaces
+                      ? selectedPersonToReassign[index]
+                        ? selectedPersonToReassign[index]?.isHidden
+                        : selectedPersonToCreate[index]
+                          ? false
+                          : face.person?.isHidden
+                      : false}
+                    persistentBorder={isSelectingFaces ? selectedPersonToRemove[index] : false}
                   />
                 </div>
                 {#if !selectedPersonToCreate[index]}
@@ -239,40 +400,100 @@
                     {/if}
                   </p>
                 {/if}
-
-                <div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
-                  {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
-                    <button on:click={() => handleReset(index)} class="flex h-full w-full">
-                      <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
-                        <div>
-                          <Icon path={mdiRestart} size={18} />
+                {#if !isSelectingFaces}
+                  <div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
+                    {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
+                      <button on:click={() => handleReset(index)} class="flex h-full w-full">
+                        <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
+                          <div>
+                            <Icon path={mdiRestart} size={18} />
+                          </div>
                         </div>
-                      </div>
-                    </button>
-                  {:else}
-                    <button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
+                      </button>
+                    {:else}
+                      <button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
+                        <div
+                          class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
+                        />
+                      </button>
+                    {/if}
+                  </div>
+                {/if}
+              </div>
+            </div>
+          {/if}
+        {/each}
+      {/if}
+    </div>
+    {#if selectedPersonToAdd.length > 0}
+      Faces To add
+      <div class="mt-4 flex flex-wrap gap-2">
+        {#each selectedPersonToAdd as face, index}
+          {#if face}
+            <div class="relative z-[20001] h-[115px] w-[95px]">
+              <div
+                role="button"
+                tabindex={index}
+                class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
+                on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
+                on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
+                on:mouseleave={() => ($boundingBoxesArray = [])}
+                on:click={() => handleSelectFace(index)}
+                on:keydown={() => handleSelectFace(index)}
+              >
+                <div class="relative">
+                  <ImageThumbnail
+                    curve
+                    shadow
+                    url={face.person && face.person.id
+                      ? api.getPeopleThumbnailUrl(face.person.id)
+                      : face.customThumbnail}
+                    altText={'New person'}
+                    title={'New person'}
+                    widthStyle="90px"
+                    heightStyle="90px"
+                    thumbhash={null}
+                  />
+                </div>
+                {#if face.person?.name}
+                  <p class="relative mt-1 truncate font-medium" title={face.person?.name}>
+                    {face.person?.name}
+                  </p>{/if}
+                {#if !isSelectingFaces}
+                  <div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-red-700">
+                    <button on:click={() => handleRemoveAddedFace(index)} class="flex h-full w-full">
                       <div
                         class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
                       />
                     </button>
-                  {/if}
-                </div>
+                  </div>
+                {/if}
               </div>
             </div>
           {/if}
         {/each}
-      {/if}
-    </div>
+      </div>
+    {/if}
   </div>
 </section>
 
 {#if showSeletecFaces}
   <AssignFaceSidePanel
-    {peopleWithFaces}
+    personWithFace={peopleWithFaces[editedPersonIndex]}
     {allPeople}
-    {editedPersonIndex}
     on:close={() => (showSeletecFaces = false)}
     on:createPerson={(event) => handleCreatePerson(event.detail)}
     on:reassign={(event) => handleReassignFace(event.detail)}
   />
 {/if}
+
+{#if showUnassignedFaces}
+  <UnassignedFacesSidePannel
+    {allPeople}
+    {unassignedFaces}
+    {selectedPersonToAdd}
+    on:close={() => (showUnassignedFaces = false)}
+    on:createPerson={(event) => handleCreatePersonFromUnassignedFace(event.detail)}
+    on:reassign={(event) => handleReassignFaceFromUnassignedFace(event.detail)}
+  />
+{/if}

+ 120 - 0
web/src/lib/components/faces-page/unassigned-faces-side-pannel.svelte

@@ -0,0 +1,120 @@
+<script lang="ts">
+  import { fly } from 'svelte/transition';
+  import { linear } from 'svelte/easing';
+
+  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
+
+  import { mdiAccountOff, mdiArrowLeftThin } from '@mdi/js';
+  import Icon from '../elements/icon.svelte';
+
+  import { createEventDispatcher } from 'svelte';
+  import AssignFaceSidePanel from './assign-face-side-panel.svelte';
+  import type { PersonResponseDto } from '@api';
+  import type { FaceWithGeneretedThumbnail } from '$lib/utils/people-utils';
+  import { boundingBoxesArray } from '$lib/stores/people.store';
+
+  export let unassignedFaces: (FaceWithGeneretedThumbnail | null)[];
+  export let allPeople: PersonResponseDto[];
+  export let selectedPersonToAdd: FaceWithGeneretedThumbnail[];
+
+  let showSeletecFaces = false;
+  let personSelected: FaceWithGeneretedThumbnail;
+  const dispatch = createEventDispatcher();
+  const handleBackButton = () => {
+    dispatch('close');
+  };
+
+  const handleSelectedFace = (index: number) => {
+    const face = unassignedFaces[index];
+    if (face) {
+      personSelected = face;
+      showSeletecFaces = true;
+    }
+  };
+
+  const handleCreatePerson = (newFeaturePhoto: string | null) => {
+    showSeletecFaces = false;
+    if (newFeaturePhoto) {
+      personSelected.customThumbnail = newFeaturePhoto;
+      dispatch('createPerson', personSelected);
+    } else {
+      dispatch('close');
+    }
+  };
+
+  const handleReassignFace = (person: PersonResponseDto | null) => {
+    if (person) {
+      showSeletecFaces = false;
+      personSelected.person = person;
+      dispatch('reassign', personSelected);
+    } else {
+      dispatch('close');
+    }
+  };
+</script>
+
+<section
+  transition:fly={{ x: 360, duration: 100, easing: linear }}
+  class="absolute top-0 z-[2001] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
+>
+  <div class="flex place-items-center justify-between gap-2">
+    <div class="flex items-center gap-2">
+      <button
+        class="flex place-content-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
+        on:click={handleBackButton}
+      >
+        <div>
+          <Icon path={mdiArrowLeftThin} size="24" />
+        </div>
+      </button>
+      <p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Faces Available</p>
+    </div>
+  </div>
+  {#if unassignedFaces.length > 0}
+    <div class="px-4 py-4 text-sm">
+      <div class="mt-4 flex flex-wrap gap-2">
+        {#each unassignedFaces as face, index}
+          {#if face && !selectedPersonToAdd.some((faceToAdd) => face && faceToAdd.id === face.id)}
+            <div class="relative z-[20001] h-[115px] w-[95px]">
+              <button
+                tabindex={index}
+                class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
+                on:focus={() => (face ? ($boundingBoxesArray = [face]) : '')}
+                on:mouseover={() => (face ? ($boundingBoxesArray = [face]) : '')}
+                on:mouseleave={() => ($boundingBoxesArray = [])}
+                on:click={() => handleSelectedFace(index)}
+                on:keydown={() => handleSelectedFace(index)}
+              >
+                <ImageThumbnail
+                  curve
+                  shadow
+                  url={face.customThumbnail}
+                  title="Available face"
+                  altText="Available face"
+                  widthStyle="90px"
+                  heightStyle="90px"
+                  thumbhash={null}
+                />
+              </button>
+            </div>
+          {/if}
+        {/each}
+      </div>
+    </div>
+  {:else}
+    <div class="flex items-center justify-center">
+      <Icon path={mdiAccountOff} size="3.5em" />
+      <p class="mt-5 font-medium">No faces available</p>
+    </div>
+  {/if}
+</section>
+
+{#if showSeletecFaces}
+  <AssignFaceSidePanel
+    personWithFace={personSelected}
+    {allPeople}
+    on:close={() => (showSeletecFaces = false)}
+    on:createPerson={(event) => handleCreatePerson(event.detail)}
+    on:reassign={(event) => handleReassignFace(event.detail)}
+  />
+{/if}

+ 5 - 0
web/src/lib/utils/people-utils.ts

@@ -1,4 +1,5 @@
 import type { Faces } from '$lib/stores/people.store';
+import type { AssetFaceResponseDto } from '@api';
 import type { ZoomImageWheelState } from '@zoom-image/core';
 
 const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
@@ -19,6 +20,10 @@ export interface boundingBox {
   height: number;
 }
 
+export interface FaceWithGeneretedThumbnail extends AssetFaceResponseDto {
+  customThumbnail: string;
+}
+
 export const getBoundingBox = (
   faces: Faces[],
   zoom: ZoomImageWheelState,

+ 42 - 1
web/src/lib/utils/person.ts

@@ -1,4 +1,4 @@
-import type { PersonResponseDto } from '@api';
+import type { AssetFaceResponseDto, PersonResponseDto } from '@api';
 
 export const searchNameLocal = (
   name: string,
@@ -34,3 +34,44 @@ export const searchNameLocal = (
 export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
   return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
 };
+
+export const zoomImageToBase64 = async (
+  face: AssetFaceResponseDto,
+  photoViewer: HTMLImageElement | null,
+): Promise<string | null> => {
+  if (photoViewer === null) {
+    return null;
+  }
+  const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2 } = face;
+
+  const coordinates = {
+    x1: (photoViewer.naturalWidth / face.imageWidth) * x1,
+    x2: (photoViewer.naturalWidth / face.imageWidth) * x2,
+    y1: (photoViewer.naturalHeight / face.imageHeight) * y1,
+    y2: (photoViewer.naturalHeight / face.imageHeight) * y2,
+  };
+
+  const faceWidth = coordinates.x2 - coordinates.x1;
+  const faceHeight = coordinates.y2 - coordinates.y1;
+
+  const faceImage = new Image();
+  faceImage.src = photoViewer.src;
+
+  await new Promise((resolve) => {
+    faceImage.onload = resolve;
+    faceImage.onerror = () => resolve(null);
+  });
+
+  const canvas = document.createElement('canvas');
+  canvas.width = faceWidth;
+  canvas.height = faceHeight;
+
+  const ctx = canvas.getContext('2d');
+  if (ctx) {
+    ctx.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
+
+    return canvas.toDataURL();
+  } else {
+    return null;
+  }
+};