Explorar o código

feat(web): re-assign person faces (2) (#4949)

* feat: unassign person faces

* multiple improvements

* chore: regenerate api

* feat: improve face interactions in photos

* fix: tests

* fix: tests

* optimize

* fix: wrong assignment on complex-multiple re-assignments

* fix: thumbnails with large photos

* fix: complex reassign

* fix: don't send people with faces

* fix: person thumbnail generation

* chore: regenerate api

* add tess

* feat: face box even when zoomed

* fix: change feature photo

* feat: make the blue icon hoverable

* chore: regenerate api

* feat: use websocket

* fix: loading spinner when clicking on the done button

* fix: use the svelte way

* fix: tests

* simplify

* fix: unused vars

* fix: remove unused code

* fix: add migration

* chore: regenerate api

* ci: add unit tests

* chore: regenerate api

* feat: if a new person is created for a face and the server takes more than 15 seconds to generate the person thumbnail, don't wait for it

* reorganize

* chore: regenerate api

* feat: global edit

* pr feedback

* pr feedback

* simplify

* revert test

* fix: face generation

* fix: tests

* fix: face generation

* fix merge

* feat: search names in unmerge face selector modal

* fix: merge face selector

* simplify feature photo generation

* fix: change endpoint

* pr feedback

* chore: fix merge

* chore: fix merge

* fix: tests

* fix: edit & hide buttons

* fix: tests

* feat: show if person is hidden

* feat: rename face to person

* feat: split in new panel

* copy-paste-error

* pr feedback

* fix: feature photo

* do not leak faces

* fix: unmerge modal

* fix: merge modal event

* feat(server): remove duplicates

* fix: title for image thumbnails

* fix: disable side panel when there's no face until next PR

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
martin hai 1 ano
pai
achega
7702560b12
Modificáronse 74 ficheiros con 4882 adicións e 283 borrados
  1. 586 2
      cli/src/api/open-api/api.ts
  2. 21 0
      mobile/openapi/.openapi-generator/FILES
  3. 10 0
      mobile/openapi/README.md
  4. 22 0
      mobile/openapi/doc/AssetFaceResponseDto.md
  5. 15 0
      mobile/openapi/doc/AssetFaceUpdateDto.md
  6. 16 0
      mobile/openapi/doc/AssetFaceUpdateItem.md
  7. 21 0
      mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md
  8. 1 1
      mobile/openapi/doc/AssetResponseDto.md
  9. 127 0
      mobile/openapi/doc/FaceApi.md
  10. 15 0
      mobile/openapi/doc/FaceDto.md
  11. 110 0
      mobile/openapi/doc/PersonApi.md
  12. 20 0
      mobile/openapi/doc/PersonWithFacesResponseDto.md
  13. 7 0
      mobile/openapi/lib/api.dart
  14. 122 0
      mobile/openapi/lib/api/face_api.dart
  15. 96 0
      mobile/openapi/lib/api/person_api.dart
  16. 12 0
      mobile/openapi/lib/api_client.dart
  17. 158 0
      mobile/openapi/lib/model/asset_face_response_dto.dart
  18. 98 0
      mobile/openapi/lib/model/asset_face_update_dto.dart
  19. 106 0
      mobile/openapi/lib/model/asset_face_update_item.dart
  20. 146 0
      mobile/openapi/lib/model/asset_face_without_person_response_dto.dart
  21. 2 2
      mobile/openapi/lib/model/asset_response_dto.dart
  22. 98 0
      mobile/openapi/lib/model/face_dto.dart
  23. 142 0
      mobile/openapi/lib/model/person_with_faces_response_dto.dart
  24. 62 0
      mobile/openapi/test/asset_face_response_dto_test.dart
  25. 27 0
      mobile/openapi/test/asset_face_update_dto_test.dart
  26. 32 0
      mobile/openapi/test/asset_face_update_item_test.dart
  27. 57 0
      mobile/openapi/test/asset_face_without_person_response_dto_test.dart
  28. 1 1
      mobile/openapi/test/asset_response_dto_test.dart
  29. 31 0
      mobile/openapi/test/face_api_test.dart
  30. 27 0
      mobile/openapi/test/face_dto_test.dart
  31. 10 0
      mobile/openapi/test/person_api_test.dart
  32. 52 0
      mobile/openapi/test/person_with_faces_response_dto_test.dart
  33. 343 1
      server/immich-openapi-specs.json
  34. 8 0
      server/src/domain/access/access.core.ts
  35. 22 13
      server/src/domain/asset/response-dto/asset-response.dto.ts
  36. 1 1
      server/src/domain/job/job.service.ts
  37. 57 6
      server/src/domain/person/person.dto.ts
  38. 143 43
      server/src/domain/person/person.service.spec.ts
  39. 93 14
      server/src/domain/person/person.service.ts
  40. 1 0
      server/src/domain/repositories/access.repository.ts
  41. 5 1
      server/src/domain/repositories/person.repository.ts
  42. 2 0
      server/src/immich/app.module.ts
  43. 28 0
      server/src/immich/controllers/face.controller.ts
  44. 1 0
      server/src/immich/controllers/index.ts
  45. 15 0
      server/src/immich/controllers/person.controller.ts
  46. 2 3
      server/src/infra/entities/person.entity.ts
  47. 18 0
      server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts
  48. 18 0
      server/src/infra/repositories/access.repository.ts
  49. 42 0
      server/src/infra/repositories/person.repository.ts
  50. 133 12
      server/src/infra/sql/person.repository.sql
  51. 3 4
      server/test/fixtures/person.stub.ts
  52. 1 0
      server/test/repositories/access.repository.mock.ts
  53. 5 1
      server/test/repositories/person.repository.mock.ts
  54. 3 0
      web/src/api/api.ts
  55. 586 2
      web/src/api/open-api/api.ts
  56. 1 1
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  57. 95 46
      web/src/lib/components/asset-viewer/detail-panel.svelte
  58. 19 7
      web/src/lib/components/asset-viewer/photo-viewer.svelte
  59. 1 1
      web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
  60. 1 1
      web/src/lib/components/elements/icon.svelte
  61. 246 0
      web/src/lib/components/faces-page/assign-face-side-panel.svelte
  62. 16 85
      web/src/lib/components/faces-page/merge-face-selector.svelte
  63. 2 2
      web/src/lib/components/faces-page/merge-suggestion-modal.svelte
  64. 5 5
      web/src/lib/components/faces-page/people-card.svelte
  65. 106 0
      web/src/lib/components/faces-page/people-list.svelte
  66. 278 0
      web/src/lib/components/faces-page/person-side-panel.svelte
  67. 2 2
      web/src/lib/components/faces-page/show-hide.svelte
  68. 190 0
      web/src/lib/components/faces-page/unmerge-face-selector.svelte
  69. 2 0
      web/src/lib/stores/assets.store.ts
  70. 12 0
      web/src/lib/stores/people.store.ts
  71. 71 0
      web/src/lib/utils/people-utils.ts
  72. 4 0
      web/src/lib/utils/person.ts
  73. 10 10
      web/src/routes/(user)/people/+page.svelte
  74. 41 16
      web/src/routes/(user)/people/[personId]/+page.svelte

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

@@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = {
 
 export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum];
 
+/**
+ * 
+ * @export
+ * @interface AssetFaceResponseDto
+ */
+export interface AssetFaceResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxX1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxX2': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxY1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxY2': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'imageHeight': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'imageWidth': number;
+    /**
+     * 
+     * @type {PersonResponseDto}
+     * @memberof AssetFaceResponseDto
+     */
+    'person': PersonResponseDto | null;
+}
+/**
+ * 
+ * @export
+ * @interface AssetFaceUpdateDto
+ */
+export interface AssetFaceUpdateDto {
+    /**
+     * 
+     * @type {Array<AssetFaceUpdateItem>}
+     * @memberof AssetFaceUpdateDto
+     */
+    'data': Array<AssetFaceUpdateItem>;
+}
+/**
+ * 
+ * @export
+ * @interface AssetFaceUpdateItem
+ */
+export interface AssetFaceUpdateItem {
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceUpdateItem
+     */
+    'assetId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceUpdateItem
+     */
+    'personId': string;
+}
+/**
+ * 
+ * @export
+ * @interface AssetFaceWithoutPersonResponseDto
+ */
+export interface AssetFaceWithoutPersonResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxX1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxX2': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxY1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxY2': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'imageHeight': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'imageWidth': number;
+}
 /**
  * 
  * @export
@@ -842,10 +978,10 @@ export interface AssetResponseDto {
     'ownerId': string;
     /**
      * 
-     * @type {Array<PersonResponseDto>}
+     * @type {Array<PersonWithFacesResponseDto>}
      * @memberof AssetResponseDto
      */
-    'people'?: Array<PersonResponseDto>;
+    'people'?: Array<PersonWithFacesResponseDto>;
     /**
      * 
      * @type {boolean}
@@ -1672,6 +1808,19 @@ export interface ExifResponseDto {
      */
     'timeZone'?: string | null;
 }
+/**
+ * 
+ * @export
+ * @interface FaceDto
+ */
+export interface FaceDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceDto
+     */
+    'id': string;
+}
 /**
  * 
  * @export
@@ -2564,6 +2713,49 @@ export interface PersonUpdateDto {
      */
     'name'?: string;
 }
+/**
+ * 
+ * @export
+ * @interface PersonWithFacesResponseDto
+ */
+export interface PersonWithFacesResponseDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'birthDate': string | null;
+    /**
+     * 
+     * @type {Array<AssetFaceWithoutPersonResponseDto>}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'faces': Array<AssetFaceWithoutPersonResponseDto>;
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'isHidden': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'name': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'thumbnailPath': string;
+}
 /**
  * 
  * @export
@@ -11349,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI {
 }
 
 
+/**
+ * FaceApi - axios parameter creator
+ * @export
+ */
+export const FaceApiAxiosParamCreator = function (configuration?: Configuration) {
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('getFaces', 'id', id)
+            const localVarPath = `/face`;
+            // 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: 'GET', ...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)
+
+            if (id !== undefined) {
+                localVarQueryParameter['id'] = id;
+            }
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {FaceDto} faceDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('reassignFacesById', 'id', id)
+            // verify required parameter 'faceDto' is not null or undefined
+            assertParamExists('reassignFacesById', 'faceDto', faceDto)
+            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: 'PUT', ...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)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+    }
+};
+
+/**
+ * FaceApi - functional programming interface
+ * @export
+ */
+export const FaceApiFp = function(configuration?: Configuration) {
+    const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration)
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetFaceResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {FaceDto} faceDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+    }
+};
+
+/**
+ * FaceApi - factory interface
+ * @export
+ */
+export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+    const localVarFp = FaceApiFp(configuration)
+    return {
+        /**
+         * 
+         * @param {FaceApiGetFacesRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetFaceResponseDto>> {
+            return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
+            return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath));
+        },
+    };
+};
+
+/**
+ * Request parameters for getFaces operation in FaceApi.
+ * @export
+ * @interface FaceApiGetFacesRequest
+ */
+export interface FaceApiGetFacesRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceApiGetFaces
+     */
+    readonly id: string
+}
+
+/**
+ * Request parameters for reassignFacesById operation in FaceApi.
+ * @export
+ * @interface FaceApiReassignFacesByIdRequest
+ */
+export interface FaceApiReassignFacesByIdRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceApiReassignFacesById
+     */
+    readonly id: string
+
+    /**
+     * 
+     * @type {FaceDto}
+     * @memberof FaceApiReassignFacesById
+     */
+    readonly faceDto: FaceDto
+}
+
+/**
+ * FaceApi - object-oriented interface
+ * @export
+ * @class FaceApi
+ * @extends {BaseAPI}
+ */
+export class FaceApi extends BaseAPI {
+    /**
+     * 
+     * @param {FaceApiGetFacesRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof FaceApi
+     */
+    public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) {
+        return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof FaceApi
+     */
+    public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) {
+        return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath));
+    }
+}
+
+
 /**
  * JobApi - axios parameter creator
  * @export
@@ -13180,6 +13599,44 @@ export class PartnerApi extends BaseAPI {
  */
 export const PersonApiAxiosParamCreator = function (configuration?: Configuration) {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        createPerson: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/person`;
+            // 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: 'POST', ...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,
+            };
+        },
         /**
          * 
          * @param {boolean} [withHidden] 
@@ -13439,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {AssetFaceUpdateDto} assetFaceUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('reassignFaces', 'id', id)
+            // verify required parameter 'assetFaceUpdateDto' is not null or undefined
+            assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto)
+            const localVarPath = `/person/{id}/reassign`
+                .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: 'PUT', ...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)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetFaceUpdateDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {PeopleUpdateDto} peopleUpdateDto 
@@ -13541,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
 export const PersonApiFp = function(configuration?: Configuration) {
     const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {boolean} [withHidden] 
@@ -13602,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {AssetFaceUpdateDto} assetFaceUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {PeopleUpdateDto} peopleUpdateDto 
@@ -13633,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) {
 export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
     const localVarFp = PersonApiFp(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        createPerson(options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
+            return localVarFp.createPerson(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
@@ -13687,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
         mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
             return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
+            return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
@@ -13799,6 +14341,27 @@ export interface PersonApiMergePersonRequest {
     readonly mergePersonDto: MergePersonDto
 }
 
+/**
+ * Request parameters for reassignFaces operation in PersonApi.
+ * @export
+ * @interface PersonApiReassignFacesRequest
+ */
+export interface PersonApiReassignFacesRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonApiReassignFaces
+     */
+    readonly id: string
+
+    /**
+     * 
+     * @type {AssetFaceUpdateDto}
+     * @memberof PersonApiReassignFaces
+     */
+    readonly assetFaceUpdateDto: AssetFaceUpdateDto
+}
+
 /**
  * Request parameters for updatePeople operation in PersonApi.
  * @export
@@ -13841,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest {
  * @extends {BaseAPI}
  */
 export class PersonApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof PersonApi
+     */
+    public createPerson(options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
@@ -13907,6 +14480,17 @@ export class PersonApi extends BaseAPI {
         return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof PersonApi
+     */
+    public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.

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

@@ -24,6 +24,10 @@ doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckItem.md
 doc/AssetBulkUploadCheckResponseDto.md
 doc/AssetBulkUploadCheckResult.md
+doc/AssetFaceResponseDto.md
+doc/AssetFaceUpdateDto.md
+doc/AssetFaceUpdateItem.md
+doc/AssetFaceWithoutPersonResponseDto.md
 doc/AssetFileUploadResponseDto.md
 doc/AssetIdsDto.md
 doc/AssetIdsResponseDto.md
@@ -60,6 +64,8 @@ doc/DownloadInfoDto.md
 doc/DownloadResponseDto.md
 doc/EntityType.md
 doc/ExifResponseDto.md
+doc/FaceApi.md
+doc/FaceDto.md
 doc/FileChecksumDto.md
 doc/FileChecksumResponseDto.md
 doc/FileReportDto.md
@@ -100,6 +106,7 @@ doc/PersonApi.md
 doc/PersonResponseDto.md
 doc/PersonStatisticsResponseDto.md
 doc/PersonUpdateDto.md
+doc/PersonWithFacesResponseDto.md
 doc/QueueStatusDto.md
 doc/ReactionLevel.md
 doc/ReactionType.md
@@ -177,6 +184,7 @@ lib/api/api_key_api.dart
 lib/api/asset_api.dart
 lib/api/audit_api.dart
 lib/api/authentication_api.dart
+lib/api/face_api.dart
 lib/api/job_api.dart
 lib/api/library_api.dart
 lib/api/o_auth_api.dart
@@ -213,6 +221,10 @@ lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_item.dart
 lib/model/asset_bulk_upload_check_response_dto.dart
 lib/model/asset_bulk_upload_check_result.dart
+lib/model/asset_face_response_dto.dart
+lib/model/asset_face_update_dto.dart
+lib/model/asset_face_update_item.dart
+lib/model/asset_face_without_person_response_dto.dart
 lib/model/asset_file_upload_response_dto.dart
 lib/model/asset_ids_dto.dart
 lib/model/asset_ids_response_dto.dart
@@ -247,6 +259,7 @@ lib/model/download_info_dto.dart
 lib/model/download_response_dto.dart
 lib/model/entity_type.dart
 lib/model/exif_response_dto.dart
+lib/model/face_dto.dart
 lib/model/file_checksum_dto.dart
 lib/model/file_checksum_response_dto.dart
 lib/model/file_report_dto.dart
@@ -282,6 +295,7 @@ lib/model/people_update_item.dart
 lib/model/person_response_dto.dart
 lib/model/person_statistics_response_dto.dart
 lib/model/person_update_dto.dart
+lib/model/person_with_faces_response_dto.dart
 lib/model/queue_status_dto.dart
 lib/model/reaction_level.dart
 lib/model/reaction_type.dart
@@ -367,6 +381,10 @@ test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_item_test.dart
 test/asset_bulk_upload_check_response_dto_test.dart
 test/asset_bulk_upload_check_result_test.dart
+test/asset_face_response_dto_test.dart
+test/asset_face_update_dto_test.dart
+test/asset_face_update_item_test.dart
+test/asset_face_without_person_response_dto_test.dart
 test/asset_file_upload_response_dto_test.dart
 test/asset_ids_dto_test.dart
 test/asset_ids_response_dto_test.dart
@@ -403,6 +421,8 @@ test/download_info_dto_test.dart
 test/download_response_dto_test.dart
 test/entity_type_test.dart
 test/exif_response_dto_test.dart
+test/face_api_test.dart
+test/face_dto_test.dart
 test/file_checksum_dto_test.dart
 test/file_checksum_response_dto_test.dart
 test/file_report_dto_test.dart
@@ -443,6 +463,7 @@ test/person_api_test.dart
 test/person_response_dto_test.dart
 test/person_statistics_response_dto_test.dart
 test/person_update_dto_test.dart
+test/person_with_faces_response_dto_test.dart
 test/queue_status_dto_test.dart
 test/reaction_level_test.dart
 test/reaction_type_test.dart

+ 10 - 0
mobile/openapi/README.md

@@ -133,6 +133,8 @@ Class | Method | HTTP request | Description
 *AuthenticationApi* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices | 
 *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | 
 *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} | 
 *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 | 
@@ -153,12 +155,14 @@ Class | Method | HTTP request | Description
 *PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner | 
 *PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} | 
 *PartnerApi* | [**updatePartner**](doc//PartnerApi.md#updatepartner) | **PUT** /partner/{id} | 
+*PersonApi* | [**createPerson**](doc//PersonApi.md#createperson) | **POST** /person | 
 *PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person | 
 *PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} | 
 *PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | 
 *PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics | 
 *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | 
 *PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | 
+*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | 
 *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | 
 *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
@@ -224,6 +228,10 @@ Class | Method | HTTP request | Description
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
  - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
  - [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
+ - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
+ - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
+ - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
+ - [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
  - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
  - [AssetIdsDto](doc//AssetIdsDto.md)
  - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
@@ -258,6 +266,7 @@ Class | Method | HTTP request | Description
  - [DownloadResponseDto](doc//DownloadResponseDto.md)
  - [EntityType](doc//EntityType.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
+ - [FaceDto](doc//FaceDto.md)
  - [FileChecksumDto](doc//FileChecksumDto.md)
  - [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
  - [FileReportDto](doc//FileReportDto.md)
@@ -293,6 +302,7 @@ Class | Method | HTTP request | Description
  - [PersonResponseDto](doc//PersonResponseDto.md)
  - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
  - [PersonUpdateDto](doc//PersonUpdateDto.md)
+ - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
  - [QueueStatusDto](doc//QueueStatusDto.md)
  - [ReactionLevel](doc//ReactionLevel.md)
  - [ReactionType](doc//ReactionType.md)

+ 22 - 0
mobile/openapi/doc/AssetFaceResponseDto.md

@@ -0,0 +1,22 @@
+# openapi.model.AssetFaceResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**boundingBoxX1** | **int** |  | 
+**boundingBoxX2** | **int** |  | 
+**boundingBoxY1** | **int** |  | 
+**boundingBoxY2** | **int** |  | 
+**id** | **String** |  | 
+**imageHeight** | **int** |  | 
+**imageWidth** | **int** |  | 
+**person** | [**PersonResponseDto**](PersonResponseDto.md) |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 15 - 0
mobile/openapi/doc/AssetFaceUpdateDto.md

@@ -0,0 +1,15 @@
+# openapi.model.AssetFaceUpdateDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**data** | [**List<AssetFaceUpdateItem>**](AssetFaceUpdateItem.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)
+
+

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

@@ -0,0 +1,16 @@
+# openapi.model.AssetFaceUpdateItem
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**assetId** | **String** |  | 
+**personId** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 21 - 0
mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md

@@ -0,0 +1,21 @@
+# openapi.model.AssetFaceWithoutPersonResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**boundingBoxX1** | **int** |  | 
+**boundingBoxX2** | **int** |  | 
+**boundingBoxY1** | **int** |  | 
+**boundingBoxY2** | **int** |  | 
+**id** | **String** |  | 
+**imageHeight** | **int** |  | 
+**imageWidth** | **int** |  | 
+
+[[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 - 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<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []]
+**people** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) |  | [optional] [default to const []]
 **resized** | **bool** |  | 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [optional] [default to const []]

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

@@ -0,0 +1,127 @@
+# openapi.api.FaceApi
+
+## Load the API package
+```dart
+import 'package:openapi/api.dart';
+```
+
+All URIs are relative to */api*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+[**getFaces**](FaceApi.md#getfaces) | **GET** /face | 
+[**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | 
+
+
+# **getFaces**
+> List<AssetFaceResponseDto> getFaces(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.getFaces(id);
+    print(result);
+} catch (e) {
+    print('Exception when calling FaceApi->getFaces: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+
+### Return type
+
+[**List<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)
+
+# **reassignFacesById**
+> PersonResponseDto reassignFacesById(id, faceDto)
+
+
+
+### 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 | 
+final faceDto = FaceDto(); // FaceDto | 
+
+try {
+    final result = api_instance.reassignFacesById(id, faceDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling FaceApi->reassignFacesById: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+ **faceDto** | [**FaceDto**](FaceDto.md)|  | 
+
+### Return type
+
+[**PersonResponseDto**](PersonResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **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)
+

+ 15 - 0
mobile/openapi/doc/FaceDto.md

@@ -0,0 +1,15 @@
+# openapi.model.FaceDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 110 - 0
mobile/openapi/doc/PersonApi.md

@@ -9,16 +9,69 @@ All URIs are relative to */api*
 
 Method | HTTP request | Description
 ------------- | ------------- | -------------
+[**createPerson**](PersonApi.md#createperson) | **POST** /person | 
 [**getAllPeople**](PersonApi.md#getallpeople) | **GET** /person | 
 [**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} | 
 [**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | 
 [**getPersonStatistics**](PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics | 
 [**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | 
 [**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge | 
+[**reassignFaces**](PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | 
 [**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person | 
 [**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} | 
 
 
+# **createPerson**
+> PersonResponseDto createPerson()
+
+
+
+### 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 = PersonApi();
+
+try {
+    final result = api_instance.createPerson();
+    print(result);
+} catch (e) {
+    print('Exception when calling PersonApi->createPerson: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**PersonResponseDto**](PersonResponseDto.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)
+
 # **getAllPeople**
 > PeopleResponseDto getAllPeople(withHidden)
 
@@ -351,6 +404,63 @@ 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)
 
+# **reassignFaces**
+> List<PersonResponseDto> reassignFaces(id, assetFaceUpdateDto)
+
+
+
+### 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 = PersonApi();
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final assetFaceUpdateDto = AssetFaceUpdateDto(); // AssetFaceUpdateDto | 
+
+try {
+    final result = api_instance.reassignFaces(id, assetFaceUpdateDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling PersonApi->reassignFaces: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+ **assetFaceUpdateDto** | [**AssetFaceUpdateDto**](AssetFaceUpdateDto.md)|  | 
+
+### Return type
+
+[**List<PersonResponseDto>**](PersonResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **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)
+
 # **updatePeople**
 > List<BulkIdResponseDto> updatePeople(peopleUpdateDto)
 

+ 20 - 0
mobile/openapi/doc/PersonWithFacesResponseDto.md

@@ -0,0 +1,20 @@
+# openapi.model.PersonWithFacesResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**birthDate** | [**DateTime**](DateTime.md) |  | 
+**faces** | [**List<AssetFaceWithoutPersonResponseDto>**](AssetFaceWithoutPersonResponseDto.md) |  | [default to const []]
+**id** | **String** |  | 
+**isHidden** | **bool** |  | 
+**name** | **String** |  | 
+**thumbnailPath** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

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

@@ -34,6 +34,7 @@ part 'api/album_api.dart';
 part 'api/asset_api.dart';
 part 'api/audit_api.dart';
 part 'api/authentication_api.dart';
+part 'api/face_api.dart';
 part 'api/job_api.dart';
 part 'api/library_api.dart';
 part 'api/o_auth_api.dart';
@@ -63,6 +64,10 @@ part 'model/asset_bulk_upload_check_dto.dart';
 part 'model/asset_bulk_upload_check_item.dart';
 part 'model/asset_bulk_upload_check_response_dto.dart';
 part 'model/asset_bulk_upload_check_result.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_file_upload_response_dto.dart';
 part 'model/asset_ids_dto.dart';
 part 'model/asset_ids_response_dto.dart';
@@ -97,6 +102,7 @@ part 'model/download_info_dto.dart';
 part 'model/download_response_dto.dart';
 part 'model/entity_type.dart';
 part 'model/exif_response_dto.dart';
+part 'model/face_dto.dart';
 part 'model/file_checksum_dto.dart';
 part 'model/file_checksum_response_dto.dart';
 part 'model/file_report_dto.dart';
@@ -132,6 +138,7 @@ part 'model/people_update_item.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/queue_status_dto.dart';
 part 'model/reaction_level.dart';
 part 'model/reaction_type.dart';

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

@@ -0,0 +1,122 @@
+//
+// 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 FaceApi {
+  FaceApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'GET /face' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> getFacesWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/face';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+      queryParams.addAll(_queryParams('', 'id', id));
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<List<AssetFaceResponseDto>?> getFaces(String id,) async {
+    final response = await getFacesWithHttpInfo(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) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<AssetFaceResponseDto>') as List)
+        .cast<AssetFaceResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PUT /face/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [FaceDto] faceDto (required):
+  Future<Response> reassignFacesByIdWithHttpInfo(String id, FaceDto faceDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/face/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = faceDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [FaceDto] faceDto (required):
+  Future<PersonResponseDto?> reassignFacesById(String id, FaceDto faceDto,) async {
+    final response = await reassignFacesByIdWithHttpInfo(id, faceDto,);
+    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), 'PersonResponseDto',) as PersonResponseDto;
+    
+    }
+    return null;
+  }
+}

+ 96 - 0
mobile/openapi/lib/api/person_api.dart

@@ -16,6 +16,47 @@ class PersonApi {
 
   final ApiClient apiClient;
 
+  /// Performs an HTTP 'POST /person' operation and returns the [Response].
+  Future<Response> createPersonWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/person';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<PersonResponseDto?> createPerson() async {
+    final response = await createPersonWithHttpInfo();
+    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), 'PersonResponseDto',) as PersonResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /person' operation and returns the [Response].
   /// Parameters:
   ///
@@ -317,6 +358,61 @@ class PersonApi {
     return null;
   }
 
+  /// Performs an HTTP 'PUT /person/{id}/reassign' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
+  Future<Response> reassignFacesWithHttpInfo(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/person/{id}/reassign'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = assetFaceUpdateDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
+  Future<List<PersonResponseDto>?> reassignFaces(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async {
+    final response = await reassignFacesWithHttpInfo(id, assetFaceUpdateDto,);
+    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) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
+        .cast<PersonResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'PUT /person' operation and returns the [Response].
   /// Parameters:
   ///

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

@@ -215,6 +215,14 @@ class ApiClient {
           return AssetBulkUploadCheckResponseDto.fromJson(value);
         case 'AssetBulkUploadCheckResult':
           return AssetBulkUploadCheckResult.fromJson(value);
+        case 'AssetFaceResponseDto':
+          return AssetFaceResponseDto.fromJson(value);
+        case 'AssetFaceUpdateDto':
+          return AssetFaceUpdateDto.fromJson(value);
+        case 'AssetFaceUpdateItem':
+          return AssetFaceUpdateItem.fromJson(value);
+        case 'AssetFaceWithoutPersonResponseDto':
+          return AssetFaceWithoutPersonResponseDto.fromJson(value);
         case 'AssetFileUploadResponseDto':
           return AssetFileUploadResponseDto.fromJson(value);
         case 'AssetIdsDto':
@@ -283,6 +291,8 @@ class ApiClient {
           return EntityTypeTypeTransformer().decode(value);
         case 'ExifResponseDto':
           return ExifResponseDto.fromJson(value);
+        case 'FaceDto':
+          return FaceDto.fromJson(value);
         case 'FileChecksumDto':
           return FileChecksumDto.fromJson(value);
         case 'FileChecksumResponseDto':
@@ -353,6 +363,8 @@ class ApiClient {
           return PersonStatisticsResponseDto.fromJson(value);
         case 'PersonUpdateDto':
           return PersonUpdateDto.fromJson(value);
+        case 'PersonWithFacesResponseDto':
+          return PersonWithFacesResponseDto.fromJson(value);
         case 'QueueStatusDto':
           return QueueStatusDto.fromJson(value);
         case 'ReactionLevel':

+ 158 - 0
mobile/openapi/lib/model/asset_face_response_dto.dart

@@ -0,0 +1,158 @@
+//
+// 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 AssetFaceResponseDto {
+  /// Returns a new [AssetFaceResponseDto] instance.
+  AssetFaceResponseDto({
+    required this.boundingBoxX1,
+    required this.boundingBoxX2,
+    required this.boundingBoxY1,
+    required this.boundingBoxY2,
+    required this.id,
+    required this.imageHeight,
+    required this.imageWidth,
+    required this.person,
+  });
+
+  int boundingBoxX1;
+
+  int boundingBoxX2;
+
+  int boundingBoxY1;
+
+  int boundingBoxY2;
+
+  String id;
+
+  int imageHeight;
+
+  int imageWidth;
+
+  PersonResponseDto? person;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetFaceResponseDto &&
+     other.boundingBoxX1 == boundingBoxX1 &&
+     other.boundingBoxX2 == boundingBoxX2 &&
+     other.boundingBoxY1 == boundingBoxY1 &&
+     other.boundingBoxY2 == boundingBoxY2 &&
+     other.id == id &&
+     other.imageHeight == imageHeight &&
+     other.imageWidth == imageWidth &&
+     other.person == person;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (boundingBoxX1.hashCode) +
+    (boundingBoxX2.hashCode) +
+    (boundingBoxY1.hashCode) +
+    (boundingBoxY2.hashCode) +
+    (id.hashCode) +
+    (imageHeight.hashCode) +
+    (imageWidth.hashCode) +
+    (person == null ? 0 : person!.hashCode);
+
+  @override
+  String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person]';
+
+  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.person != null) {
+      json[r'person'] = this.person;
+    } else {
+    //  json[r'person'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [AssetFaceResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetFaceResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return AssetFaceResponseDto(
+        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')!,
+        person: PersonResponseDto.fromJson(json[r'person']),
+      );
+    }
+    return null;
+  }
+
+  static List<AssetFaceResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetFaceResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetFaceResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetFaceResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AssetFaceResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetFaceResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetFaceResponseDto-objects as value to a dart map
+  static Map<String, List<AssetFaceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetFaceResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = AssetFaceResponseDto.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',
+    'person',
+  };
+}
+

+ 98 - 0
mobile/openapi/lib/model/asset_face_update_dto.dart

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

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

+ 146 - 0
mobile/openapi/lib/model/asset_face_without_person_response_dto.dart

@@ -0,0 +1,146 @@
+//
+// 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 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,
+  });
+
+  int boundingBoxX1;
+
+  int boundingBoxX2;
+
+  int boundingBoxY1;
+
+  int boundingBoxY2;
+
+  String id;
+
+  int imageHeight;
+
+  int imageWidth;
+
+  @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;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (boundingBoxX1.hashCode) +
+    (boundingBoxX2.hashCode) +
+    (boundingBoxY1.hashCode) +
+    (boundingBoxY2.hashCode) +
+    (id.hashCode) +
+    (imageHeight.hashCode) +
+    (imageWidth.hashCode);
+
+  @override
+  String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth]';
+
+  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;
+    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) {
+    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')!,
+      );
+    }
+    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',
+  };
+}
+

+ 2 - 2
mobile/openapi/lib/model/asset_response_dto.dart

@@ -104,7 +104,7 @@ class AssetResponseDto {
 
   String ownerId;
 
-  List<PersonResponseDto> people;
+  List<PersonWithFacesResponseDto> people;
 
   bool resized;
 
@@ -299,7 +299,7 @@ class AssetResponseDto {
         originalPath: mapValueOfType<String>(json, r'originalPath')!,
         owner: UserResponseDto.fromJson(json[r'owner']),
         ownerId: mapValueOfType<String>(json, r'ownerId')!,
-        people: PersonResponseDto.listFromJson(json[r'people']),
+        people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
         resized: mapValueOfType<bool>(json, r'resized')!,
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
         stack: AssetResponseDto.listFromJson(json[r'stack']),

+ 98 - 0
mobile/openapi/lib/model/face_dto.dart

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

+ 142 - 0
mobile/openapi/lib/model/person_with_faces_response_dto.dart

@@ -0,0 +1,142 @@
+//
+// 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 PersonWithFacesResponseDto {
+  /// Returns a new [PersonWithFacesResponseDto] instance.
+  PersonWithFacesResponseDto({
+    required this.birthDate,
+    this.faces = const [],
+    required this.id,
+    required this.isHidden,
+    required this.name,
+    required this.thumbnailPath,
+  });
+
+  DateTime? birthDate;
+
+  List<AssetFaceWithoutPersonResponseDto> faces;
+
+  String id;
+
+  bool isHidden;
+
+  String name;
+
+  String thumbnailPath;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
+     other.birthDate == birthDate &&
+     other.faces == faces &&
+     other.id == id &&
+     other.isHidden == isHidden &&
+     other.name == name &&
+     other.thumbnailPath == thumbnailPath;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (birthDate == null ? 0 : birthDate!.hashCode) +
+    (faces.hashCode) +
+    (id.hashCode) +
+    (isHidden.hashCode) +
+    (name.hashCode) +
+    (thumbnailPath.hashCode);
+
+  @override
+  String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath]';
+
+  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;
+    }
+      json[r'faces'] = this.faces;
+      json[r'id'] = this.id;
+      json[r'isHidden'] = this.isHidden;
+      json[r'name'] = this.name;
+      json[r'thumbnailPath'] = this.thumbnailPath;
+    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) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return PersonWithFacesResponseDto(
+        birthDate: mapDateTime(json, r'birthDate', ''),
+        faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
+        id: mapValueOfType<String>(json, r'id')!,
+        isHidden: mapValueOfType<bool>(json, r'isHidden')!,
+        name: mapValueOfType<String>(json, r'name')!,
+        thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
+      );
+    }
+    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',
+  };
+}
+

+ 62 - 0
mobile/openapi/test/asset_face_response_dto_test.dart

@@ -0,0 +1,62 @@
+//
+// 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 AssetFaceResponseDto
+void main() {
+  // final instance = AssetFaceResponseDto();
+
+  group('test AssetFaceResponseDto', () {
+    // int boundingBoxX1
+    test('to test the property `boundingBoxX1`', () async {
+      // TODO
+    });
+
+    // int boundingBoxX2
+    test('to test the property `boundingBoxX2`', () async {
+      // TODO
+    });
+
+    // int boundingBoxY1
+    test('to test the property `boundingBoxY1`', () async {
+      // TODO
+    });
+
+    // int boundingBoxY2
+    test('to test the property `boundingBoxY2`', () async {
+      // TODO
+    });
+
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // int imageHeight
+    test('to test the property `imageHeight`', () async {
+      // TODO
+    });
+
+    // int imageWidth
+    test('to test the property `imageWidth`', () async {
+      // TODO
+    });
+
+    // PersonResponseDto person
+    test('to test the property `person`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 27 - 0
mobile/openapi/test/asset_face_update_dto_test.dart

@@ -0,0 +1,27 @@
+//
+// 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 AssetFaceUpdateDto
+void main() {
+  // final instance = AssetFaceUpdateDto();
+
+  group('test AssetFaceUpdateDto', () {
+    // List<AssetFaceUpdateItem> data (default value: const [])
+    test('to test the property `data`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/asset_face_update_item_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 AssetFaceUpdateItem
+void main() {
+  // final instance = AssetFaceUpdateItem();
+
+  group('test AssetFaceUpdateItem', () {
+    // String assetId
+    test('to test the property `assetId`', () async {
+      // TODO
+    });
+
+    // String personId
+    test('to test the property `personId`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 57 - 0
mobile/openapi/test/asset_face_without_person_response_dto_test.dart

@@ -0,0 +1,57 @@
+//
+// 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 AssetFaceWithoutPersonResponseDto
+void main() {
+  // final instance = AssetFaceWithoutPersonResponseDto();
+
+  group('test AssetFaceWithoutPersonResponseDto', () {
+    // int boundingBoxX1
+    test('to test the property `boundingBoxX1`', () async {
+      // TODO
+    });
+
+    // int boundingBoxX2
+    test('to test the property `boundingBoxX2`', () async {
+      // TODO
+    });
+
+    // int boundingBoxY1
+    test('to test the property `boundingBoxY1`', () async {
+      // TODO
+    });
+
+    // int boundingBoxY2
+    test('to test the property `boundingBoxY2`', () async {
+      // TODO
+    });
+
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // int imageHeight
+    test('to test the property `imageHeight`', () async {
+      // TODO
+    });
+
+    // int imageWidth
+    test('to test the property `imageWidth`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

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

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

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

@@ -0,0 +1,31 @@
+//
+// 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 FaceApi
+void main() {
+  // final instance = FaceApi();
+
+  group('tests for FaceApi', () {
+    //Future<List<AssetFaceResponseDto>> getFaces(String id) async
+    test('test getFaces', () async {
+      // TODO
+    });
+
+    //Future<PersonResponseDto> reassignFacesById(String id, FaceDto faceDto) async
+    test('test reassignFacesById', () async {
+      // TODO
+    });
+
+  });
+}

+ 27 - 0
mobile/openapi/test/face_dto_test.dart

@@ -0,0 +1,27 @@
+//
+// 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 FaceDto
+void main() {
+  // final instance = FaceDto();
+
+  group('test FaceDto', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 10 - 0
mobile/openapi/test/person_api_test.dart

@@ -17,6 +17,11 @@ void main() {
   // final instance = PersonApi();
 
   group('tests for PersonApi', () {
+    //Future<PersonResponseDto> createPerson() async
+    test('test createPerson', () async {
+      // TODO
+    });
+
     //Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
     test('test getAllPeople', () async {
       // TODO
@@ -47,6 +52,11 @@ void main() {
       // TODO
     });
 
+    //Future<List<PersonResponseDto>> reassignFaces(String id, AssetFaceUpdateDto assetFaceUpdateDto) async
+    test('test reassignFaces', () async {
+      // TODO
+    });
+
     //Future<List<BulkIdResponseDto>> updatePeople(PeopleUpdateDto peopleUpdateDto) async
     test('test updatePeople', () async {
       // TODO

+ 52 - 0
mobile/openapi/test/person_with_faces_response_dto_test.dart

@@ -0,0 +1,52 @@
+//
+// 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 PersonWithFacesResponseDto
+void main() {
+  // final instance = PersonWithFacesResponseDto();
+
+  group('test PersonWithFacesResponseDto', () {
+    // DateTime birthDate
+    test('to test the property `birthDate`', () async {
+      // TODO
+    });
+
+    // List<AssetFaceWithoutPersonResponseDto> faces (default value: const [])
+    test('to test the property `faces`', () async {
+      // TODO
+    });
+
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // bool isHidden
+    test('to test the property `isHidden`', () async {
+      // TODO
+    });
+
+    // String name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+    // String thumbnailPath
+    test('to test the property `thumbnailPath`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 343 - 1
server/immich-openapi-specs.json

@@ -3220,6 +3220,103 @@
         ]
       }
     },
+    "/face": {
+      "get": {
+        "operationId": "getFaces",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/AssetFaceResponseDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Face"
+        ]
+      }
+    },
+    "/face/{id}": {
+      "put": {
+        "operationId": "reassignFacesById",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/FaceDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/PersonResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Face"
+        ]
+      }
+    },
     "/jobs": {
       "get": {
         "operationId": "getAllJobsStatus",
@@ -4022,6 +4119,36 @@
           "Person"
         ]
       },
+      "post": {
+        "operationId": "createPerson",
+        "parameters": [],
+        "responses": {
+          "201": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/PersonResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Person"
+        ]
+      },
       "put": {
         "operationId": "updatePeople",
         "parameters": [],
@@ -4258,6 +4385,61 @@
         ]
       }
     },
+    "/person/{id}/reassign": {
+      "put": {
+        "operationId": "reassignFaces",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetFaceUpdateDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/PersonResponseDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Person"
+        ]
+      }
+    },
     "/person/{id}/statistics": {
       "get": {
         "operationId": "getPersonStatistics",
@@ -6557,6 +6739,118 @@
         ],
         "type": "object"
       },
+      "AssetFaceResponseDto": {
+        "properties": {
+          "boundingBoxX1": {
+            "type": "integer"
+          },
+          "boundingBoxX2": {
+            "type": "integer"
+          },
+          "boundingBoxY1": {
+            "type": "integer"
+          },
+          "boundingBoxY2": {
+            "type": "integer"
+          },
+          "id": {
+            "format": "uuid",
+            "type": "string"
+          },
+          "imageHeight": {
+            "type": "integer"
+          },
+          "imageWidth": {
+            "type": "integer"
+          },
+          "person": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/PersonResponseDto"
+              }
+            ],
+            "nullable": true
+          }
+        },
+        "required": [
+          "id",
+          "imageHeight",
+          "imageWidth",
+          "boundingBoxX1",
+          "boundingBoxX2",
+          "boundingBoxY1",
+          "boundingBoxY2",
+          "person"
+        ],
+        "type": "object"
+      },
+      "AssetFaceUpdateDto": {
+        "properties": {
+          "data": {
+            "items": {
+              "$ref": "#/components/schemas/AssetFaceUpdateItem"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "data"
+        ],
+        "type": "object"
+      },
+      "AssetFaceUpdateItem": {
+        "properties": {
+          "assetId": {
+            "format": "uuid",
+            "type": "string"
+          },
+          "personId": {
+            "format": "uuid",
+            "type": "string"
+          }
+        },
+        "required": [
+          "personId",
+          "assetId"
+        ],
+        "type": "object"
+      },
+      "AssetFaceWithoutPersonResponseDto": {
+        "properties": {
+          "boundingBoxX1": {
+            "type": "integer"
+          },
+          "boundingBoxX2": {
+            "type": "integer"
+          },
+          "boundingBoxY1": {
+            "type": "integer"
+          },
+          "boundingBoxY2": {
+            "type": "integer"
+          },
+          "id": {
+            "format": "uuid",
+            "type": "string"
+          },
+          "imageHeight": {
+            "type": "integer"
+          },
+          "imageWidth": {
+            "type": "integer"
+          }
+        },
+        "required": [
+          "id",
+          "imageHeight",
+          "imageWidth",
+          "boundingBoxX1",
+          "boundingBoxX2",
+          "boundingBoxY1",
+          "boundingBoxY2"
+        ],
+        "type": "object"
+      },
       "AssetFileUploadResponseDto": {
         "properties": {
           "duplicate": {
@@ -6719,7 +7013,7 @@
           },
           "people": {
             "items": {
-              "$ref": "#/components/schemas/PersonResponseDto"
+              "$ref": "#/components/schemas/PersonWithFacesResponseDto"
             },
             "type": "array"
           },
@@ -7452,6 +7746,18 @@
         },
         "type": "object"
       },
+      "FaceDto": {
+        "properties": {
+          "id": {
+            "format": "uuid",
+            "type": "string"
+          }
+        },
+        "required": [
+          "id"
+        ],
+        "type": "object"
+      },
       "FileChecksumDto": {
         "properties": {
           "filenames": {
@@ -8147,6 +8453,42 @@
         },
         "type": "object"
       },
+      "PersonWithFacesResponseDto": {
+        "properties": {
+          "birthDate": {
+            "format": "date",
+            "nullable": true,
+            "type": "string"
+          },
+          "faces": {
+            "items": {
+              "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto"
+            },
+            "type": "array"
+          },
+          "id": {
+            "type": "string"
+          },
+          "isHidden": {
+            "type": "boolean"
+          },
+          "name": {
+            "type": "string"
+          },
+          "thumbnailPath": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "birthDate",
+          "faces",
+          "id",
+          "name",
+          "thumbnailPath",
+          "isHidden"
+        ],
+        "type": "object"
+      },
       "QueueStatusDto": {
         "properties": {
           "isActive": {

+ 8 - 0
server/src/domain/access/access.core.ts

@@ -41,6 +41,8 @@ export enum Permission {
   PERSON_READ = 'person.read',
   PERSON_WRITE = 'person.write',
   PERSON_MERGE = 'person.merge',
+  PERSON_CREATE = 'person.create',
+  PERSON_REASSIGN = 'person.reassign',
 
   PARTNER_UPDATE = 'partner.update',
 }
@@ -247,6 +249,12 @@ export class AccessCore {
       case Permission.PERSON_MERGE:
         return await this.repository.person.checkOwnerAccess(authUser.id, ids);
 
+      case Permission.PERSON_CREATE:
+        return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
+
+      case Permission.PERSON_REASSIGN:
+        return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
+
       case Permission.PARTNER_UPDATE:
         return await this.repository.partner.checkUpdateAccess(authUser.id, ids);
     }

+ 22 - 13
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -1,6 +1,6 @@
-import { AssetEntity, AssetType } from '@app/infra/entities';
+import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
-import { PersonResponseDto, mapFace } from '../../person/person.dto';
+import { 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?: PersonResponseDto[];
+  people?: PersonWithFacesResponseDto[];
   /**base64 encoded sha1 hash */
   checksum!: string;
   stackParentId?: string | null;
@@ -53,6 +53,24 @@ export type AssetMapOptions = {
   withStack?: boolean;
 };
 
+const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
+  const result: PersonWithFacesResponseDto[] = [];
+  if (faces) {
+    faces.forEach((face) => {
+      if (face.person) {
+        const existingPersonEntry = result.find((item) => item.id === face.person!.id);
+        if (existingPersonEntry) {
+          existingPersonEntry.faces.push(face);
+        } else {
+          result.push({ ...face.person!, faces: [face] });
+        }
+      }
+    });
+  }
+
+  return result;
+};
+
 export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
   const { stripMetadata = false, withStack = false } = options;
 
@@ -96,16 +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: entity.faces
-      ?.map(mapFace)
-      .filter((person): person is PersonResponseDto => person !== null)
-      .reduce((people, person) => {
-        const existingPerson = people.find((p) => p.id === person.id);
-        if (!existingPerson) {
-          people.push(person);
-        }
-        return people;
-      }, [] as PersonResponseDto[]),
+    people: peopleWithFaces(entity.faces),
     checksum: entity.checksum.toString('base64'),
     stackParentId: entity.stackParentId,
     stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,

+ 1 - 1
server/src/domain/job/job.service.ts

@@ -201,7 +201,7 @@ export class JobService {
         const { id } = item.data;
         const person = await this.personRepository.getById(id);
         if (person) {
-          this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id);
+          this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, person.id);
         }
         break;
 

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

@@ -2,6 +2,7 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform, Type } from 'class-transformer';
 import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
+import { AuthUserDto } from '../auth';
 import { Optional, ValidateUUID, toBoolean } from '../domain.util';
 
 export class PersonUpdateDto {
@@ -73,6 +74,51 @@ export class PersonResponseDto {
   isHidden!: boolean;
 }
 
+export class PersonWithFacesResponseDto extends PersonResponseDto {
+  faces!: AssetFaceWithoutPersonResponseDto[];
+}
+
+export class AssetFaceWithoutPersonResponseDto {
+  @ValidateUUID()
+  id!: string;
+  @ApiProperty({ type: 'integer' })
+  imageHeight!: number;
+  @ApiProperty({ type: 'integer' })
+  imageWidth!: number;
+  @ApiProperty({ type: 'integer' })
+  boundingBoxX1!: number;
+  @ApiProperty({ type: 'integer' })
+  boundingBoxX2!: number;
+  @ApiProperty({ type: 'integer' })
+  boundingBoxY1!: number;
+  @ApiProperty({ type: 'integer' })
+  boundingBoxY2!: number;
+}
+
+export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
+  person!: PersonResponseDto | null;
+}
+
+export class AssetFaceUpdateDto {
+  @IsArray()
+  @ValidateNested({ each: true })
+  @Type(() => AssetFaceUpdateItem)
+  data!: AssetFaceUpdateItem[];
+}
+
+export class FaceDto {
+  @ValidateUUID()
+  id!: string;
+}
+
+export class AssetFaceUpdateItem {
+  @ValidateUUID()
+  personId!: string;
+
+  @ValidateUUID()
+  assetId!: string;
+}
+
 export class PersonStatisticsResponseDto {
   @ApiProperty({ type: 'integer' })
   assets!: number;
@@ -98,10 +144,15 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
   };
 }
 
-export function mapFace(face: AssetFaceEntity): PersonResponseDto | null {
-  if (face.person) {
-    return mapPerson(face.person);
-  }
-
-  return null;
+export function mapFaces(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto {
+  return {
+    id: face.id,
+    imageHeight: face.imageHeight,
+    imageWidth: face.imageWidth,
+    boundingBoxX1: face.boundingBoxX1,
+    boundingBoxX2: face.boundingBoxX2,
+    boundingBoxY1: face.boundingBoxY1,
+    boundingBoxY2: face.boundingBoxY2,
+    person: face.person?.ownerId === authUser.id ? mapPerson(face.person) : null,
+  };
 }

+ 143 - 43
server/src/domain/person/person.service.spec.ts

@@ -31,7 +31,7 @@ import {
   ISystemConfigRepository,
   WithoutProperty,
 } from '../repositories';
-import { PersonResponseDto } from './person.dto';
+import { PersonResponseDto, mapFaces } from './person.dto';
 import { PersonService } from './person.service';
 
 const responseDto: PersonResponseDto = {
@@ -339,7 +339,7 @@ describe(PersonService.name, () => {
       ).resolves.toEqual(responseDto);
 
       expect(personMock.getById).toHaveBeenCalledWith('person-1');
-      expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.assetId });
+      expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id });
       expect(personMock.getFacesByIds).toHaveBeenCalledWith([
         {
           assetId: faceStub.face1.assetId,
@@ -375,6 +375,139 @@ describe(PersonService.name, () => {
     });
   });
 
+  describe('reassignFaces', () => {
+    it('should throw an error if user has no access to the person', async () => {
+      accessMock.person.checkOwnerAccess.mockResolvedValue(new Set());
+
+      await expect(
+        sut.reassignFaces(authStub.admin, personStub.noName.id, {
+          data: [{ personId: 'asset-face-1', assetId: '' }],
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+      expect(jobMock.queue).not.toHaveBeenCalledWith();
+    });
+    it('should reassign a face', async () => {
+      accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
+      personMock.getById.mockResolvedValue(personStub.noName);
+      accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
+      personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
+      personMock.reassignFace.mockResolvedValue(1);
+      personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
+      await expect(
+        sut.reassignFaces(authStub.admin, personStub.noName.id, {
+          data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }],
+        }),
+      ).resolves.toEqual([personStub.noName]);
+
+      expect(jobMock.queue).toHaveBeenCalledWith({
+        name: JobName.GENERATE_PERSON_THUMBNAIL,
+        data: { id: personStub.newThumbnail.id },
+      });
+    });
+  });
+
+  describe('handlePersonMigration', () => {
+    it('should not move person files', async () => {
+      personMock.getById.mockResolvedValue(null);
+      await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false);
+    });
+  });
+
+  describe('getFacesById', () => {
+    it('should get the bounding boxes for an asset', async () => {
+      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId]));
+      personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
+      await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([
+        mapFaces(faceStub.primaryFace1, authStub.admin),
+      ]);
+    });
+    it('should reject if the user has not access to the asset', async () => {
+      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set());
+      personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
+      await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+    });
+  });
+
+  describe('createNewFeaturePhoto', () => {
+    it('should change person feature photo', async () => {
+      personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
+      await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
+      expect(jobMock.queue).toHaveBeenCalledWith({
+        name: JobName.GENERATE_PERSON_THUMBNAIL,
+        data: { id: personStub.newThumbnail.id },
+      });
+    });
+  });
+
+  describe('reassignFacesById', () => {
+    it('should create a new person', async () => {
+      accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
+      accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
+      personMock.getFaceById.mockResolvedValue(faceStub.face1);
+      personMock.reassignFace.mockResolvedValue(1);
+      personMock.getById.mockResolvedValue(personStub.noName);
+      personMock.getRandomFace.mockResolvedValue(null);
+      await expect(
+        sut.reassignFacesById(authStub.admin, personStub.noName.id, {
+          id: faceStub.face1.id,
+        }),
+      ).resolves.toEqual({
+        birthDate: personStub.noName.birthDate,
+        isHidden: personStub.noName.isHidden,
+        id: personStub.noName.id,
+        name: personStub.noName.name,
+        thumbnailPath: personStub.noName.thumbnailPath,
+      });
+
+      expect(jobMock.queue).not.toHaveBeenCalledWith();
+    });
+
+    it('should fail if user has not the correct permissions on the asset', async () => {
+      accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
+      accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set());
+      personMock.getFaceById.mockResolvedValue(faceStub.face1);
+      personMock.reassignFace.mockResolvedValue(1);
+      personMock.getById.mockResolvedValue(personStub.noName);
+      personMock.getRandomFace.mockResolvedValue(null);
+      await expect(
+        sut.reassignFacesById(authStub.admin, personStub.noName.id, {
+          id: faceStub.face1.id,
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+
+      expect(jobMock.queue).not.toHaveBeenCalledWith();
+    });
+  });
+
+  describe('createPerson', () => {
+    it('should create a new person', async () => {
+      personMock.create.mockResolvedValue(personStub.primaryPerson);
+      personMock.getFaceById.mockResolvedValue(faceStub.face1);
+      accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
+
+      await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson);
+    });
+  });
+
+  describe('handlePersonDelete', () => {
+    it('should stop if a person has not be found', async () => {
+      personMock.getById.mockResolvedValue(null);
+
+      await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(false);
+      expect(personMock.update).not.toHaveBeenCalled();
+      expect(storageMock.unlink).not.toHaveBeenCalled();
+    });
+    it('should delete a person', async () => {
+      personMock.getById.mockResolvedValue(personStub.primaryPerson);
+
+      await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(true);
+      expect(personMock.delete).toHaveBeenCalledWith(personStub.primaryPerson);
+      expect(storageMock.unlink).toHaveBeenCalledWith(personStub.primaryPerson.thumbnailPath);
+    });
+  });
+
   describe('handlePersonCleanup', () => {
     it('should delete people without faces', async () => {
       personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
@@ -515,6 +648,7 @@ describe(PersonService.name, () => {
       searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
       personMock.create.mockResolvedValue(personStub.noName);
       assetMock.getByIds.mockResolvedValue([assetStub.image]);
+      personMock.createFace.mockResolvedValue(faceStub.primaryFace1);
 
       await sut.handleRecognizeFaces({ id: assetStub.image.id });
 
@@ -557,16 +691,16 @@ describe(PersonService.name, () => {
       expect(mediaMock.crop).not.toHaveBeenCalled();
     });
 
-    it('should skip an person with a face asset id not found', async () => {
-      personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
-      personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
+    it('should skip a person with a face asset id not found', async () => {
+      personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
+      personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
       await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
       expect(mediaMock.crop).not.toHaveBeenCalled();
     });
 
     it('should skip a person with a face asset id without a thumbnail', async () => {
       personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
-      personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
+      personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
       assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
       await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
       expect(mediaMock.crop).not.toHaveBeenCalled();
@@ -574,7 +708,7 @@ describe(PersonService.name, () => {
 
     it('should generate a thumbnail', async () => {
       personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
-      personMock.getFacesByIds.mockResolvedValue([faceStub.middle]);
+      personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
       assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
 
       await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
@@ -601,7 +735,7 @@ describe(PersonService.name, () => {
 
     it('should generate a thumbnail without going negative', async () => {
       personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
-      personMock.getFacesByIds.mockResolvedValue([faceStub.start]);
+      personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
       assetMock.getByIds.mockResolvedValue([assetStub.image]);
 
       await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
@@ -622,7 +756,7 @@ describe(PersonService.name, () => {
 
     it('should generate a thumbnail without overflowing', async () => {
       personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
-      personMock.getFacesByIds.mockResolvedValue([faceStub.end]);
+      personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
       assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
 
       await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
@@ -646,15 +780,12 @@ describe(PersonService.name, () => {
     it('should require person.write and person.merge permission', async () => {
       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
       personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
-      personMock.prepareReassignFaces.mockResolvedValue([]);
       personMock.delete.mockResolvedValue(personStub.mergePerson);
 
       await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
         BadRequestException,
       );
 
-      expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
-
       expect(personMock.reassignFaces).not.toHaveBeenCalled();
 
       expect(personMock.delete).not.toHaveBeenCalled();
@@ -664,7 +795,6 @@ describe(PersonService.name, () => {
     it('should merge two people', async () => {
       personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
       personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
-      personMock.prepareReassignFaces.mockResolvedValue([]);
       personMock.delete.mockResolvedValue(personStub.mergePerson);
       accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
       accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
@@ -673,11 +803,6 @@ describe(PersonService.name, () => {
         { id: 'person-2', success: true },
       ]);
 
-      expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
-        newPersonId: personStub.primaryPerson.id,
-        oldPersonId: personStub.mergePerson.id,
-      });
-
       expect(personMock.reassignFaces).toHaveBeenCalledWith({
         newPersonId: personStub.primaryPerson.id,
         oldPersonId: personStub.mergePerson.id,
@@ -690,29 +815,6 @@ describe(PersonService.name, () => {
       expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
     });
 
-    it('should delete conflicting faces before merging', async () => {
-      personMock.getById.mockResolvedValue(personStub.primaryPerson);
-      personMock.getById.mockResolvedValue(personStub.mergePerson);
-      personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
-      accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
-      accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
-
-      await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
-        { id: 'person-2', success: true },
-      ]);
-
-      expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
-        newPersonId: personStub.primaryPerson.id,
-        oldPersonId: personStub.mergePerson.id,
-      });
-
-      expect(jobMock.queue).toHaveBeenCalledWith({
-        name: JobName.SEARCH_REMOVE_FACE,
-        data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
-      });
-      expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
-    });
-
     it('should throw an error when the primary person is not found', async () => {
       personMock.getById.mockResolvedValue(null);
       accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@@ -735,7 +837,6 @@ describe(PersonService.name, () => {
         { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
       ]);
 
-      expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
       expect(personMock.reassignFaces).not.toHaveBeenCalled();
       expect(personMock.delete).not.toHaveBeenCalled();
       expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
@@ -744,7 +845,6 @@ describe(PersonService.name, () => {
     it('should handle an error reassigning faces', async () => {
       personMock.getById.mockResolvedValue(personStub.primaryPerson);
       personMock.getById.mockResolvedValue(personStub.mergePerson);
-      personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
       personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
       accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
       accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));

+ 93 - 14
server/src/domain/person/person.service.ts

@@ -28,6 +28,9 @@ import {
 import { StorageCore } from '../storage';
 import { SystemConfigCore } from '../system-config';
 import {
+  AssetFaceResponseDto,
+  AssetFaceUpdateDto,
+  FaceDto,
   MergePersonDto,
   PeopleResponseDto,
   PeopleUpdateDto,
@@ -35,6 +38,7 @@ import {
   PersonSearchDto,
   PersonStatisticsResponseDto,
   PersonUpdateDto,
+  mapFaces,
   mapPerson,
 } from './person.dto';
 
@@ -80,6 +84,86 @@ export class PersonService {
     };
   }
 
+  createPerson(authUser: AuthUserDto): Promise<PersonResponseDto> {
+    return this.repository.create({ ownerId: authUser.id });
+  }
+
+  async reassignFaces(authUser: AuthUserDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
+    await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
+    const person = await this.findOrFail(personId);
+    const result: PersonResponseDto[] = [];
+    const changeFeaturePhoto: string[] = [];
+    for (const data of dto.data) {
+      const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
+
+      for (const face of faces) {
+        await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id);
+        if (person.faceAssetId === null) {
+          changeFeaturePhoto.push(person.id);
+        }
+        if (face.person && face.person.faceAssetId === face.id) {
+          changeFeaturePhoto.push(face.person.id);
+        }
+
+        await this.repository.reassignFace(face.id, personId);
+      }
+
+      result.push(person);
+    }
+    if (changeFeaturePhoto.length > 0) {
+      // Remove duplicates
+      await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto)));
+    }
+    return result;
+  }
+
+  async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
+    await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
+
+    await this.access.requirePermission(authUser, Permission.PERSON_CREATE, dto.id);
+    const face = await this.repository.getFaceById(dto.id);
+    const person = await this.findOrFail(personId);
+
+    await this.repository.reassignFace(face.id, personId);
+    if (person.faceAssetId === null) {
+      await this.createNewFeaturePhoto([person.id]);
+    }
+    if (face.person && face.person.faceAssetId === face.id) {
+      await this.createNewFeaturePhoto([face.person.id]);
+    }
+
+    return await this.findOrFail(personId).then(mapPerson);
+  }
+
+  async getFacesById(authUser: AuthUserDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
+    await this.access.requirePermission(authUser, Permission.ASSET_READ, dto.id);
+    const faces = await this.repository.getFaces(dto.id);
+    return faces.map((asset) => mapFaces(asset, authUser));
+  }
+
+  async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
+    this.logger.debug(
+      `Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
+    );
+    for (const personId of changeFeaturePhoto) {
+      const assetFace = await this.repository.getRandomFace(personId);
+
+      if (assetFace !== null) {
+        await this.repository.update({
+          id: personId,
+          faceAssetId: assetFace.id,
+        });
+
+        await this.jobRepository.queue({
+          name: JobName.GENERATE_PERSON_THUMBNAIL,
+          data: {
+            id: personId,
+          },
+        });
+      }
+    }
+  }
+
   async getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
     await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
     return this.findOrFail(id).then(mapPerson);
@@ -128,7 +212,7 @@ export class PersonService {
         throw new BadRequestException('Invalid assetId for feature face');
       }
 
-      person = await this.repository.update({ id, faceAssetId: assetId });
+      person = await this.repository.update({ id, faceAssetId: face.id });
       await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
     }
 
@@ -255,9 +339,9 @@ export class PersonService {
         personId = newPerson.id;
       }
 
-      const faceId: AssetFaceId = { assetId: asset.id, personId };
-      await this.repository.createFace({
-        ...faceId,
+      const face = await this.repository.createFace({
+        assetId: asset.id,
+        personId,
         embedding,
         imageHeight: rest.imageHeight,
         imageWidth: rest.imageWidth,
@@ -266,10 +350,11 @@ export class PersonService {
         boundingBoxY1: rest.boundingBox.y1,
         boundingBoxY2: rest.boundingBox.y2,
       });
+      const faceId: AssetFaceId = { assetId: asset.id, personId };
       await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
 
       if (newPerson) {
-        await this.repository.update({ id: personId, faceAssetId: asset.id });
+        await this.repository.update({ id: personId, faceAssetId: face.id });
         await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
       }
     }
@@ -304,14 +389,13 @@ export class PersonService {
       return false;
     }
 
-    const [face] = await this.repository.getFacesByIds([{ personId: person.id, assetId: person.faceAssetId }]);
-    if (!face) {
+    const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
+    if (face === null) {
       return false;
     }
 
     const {
       assetId,
-      personId,
       boundingBoxX1: x1,
       boundingBoxX2: x2,
       boundingBoxY1: y1,
@@ -324,8 +408,7 @@ export class PersonService {
     if (!asset?.resizePath) {
       return false;
     }
-
-    this.logger.verbose(`Cropping face for person: ${personId}`);
+    this.logger.verbose(`Cropping face for person: ${person.id}`);
     const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
     this.storageCore.ensureFolders(thumbnailPath);
 
@@ -395,10 +478,6 @@ export class PersonService {
         const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
         this.logger.log(`Merging ${mergeName} into ${primaryName}`);
 
-        const assetIds = await this.repository.prepareReassignFaces(mergeData);
-        for (const assetId of assetIds) {
-          await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } });
-        }
         await this.repository.reassignFaces(mergeData);
         await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: mergePerson.id } });
 

+ 1 - 0
server/src/domain/repositories/access.repository.ts

@@ -34,6 +34,7 @@ export interface IAccessRepository {
   };
 
   person: {
+    hasFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
     checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
   };
 

+ 5 - 1
server/src/domain/repositories/person.repository.ts

@@ -34,7 +34,7 @@ export interface IPersonRepository {
   getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
 
   getAssets(personId: string): Promise<AssetEntity[]>;
-  prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
+
   reassignFaces(data: UpdateFacesData): Promise<number>;
 
   create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
@@ -48,4 +48,8 @@ export interface IPersonRepository {
   getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
   getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
   createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
+  getFaces(assetId: string): Promise<AssetFaceEntity[]>;
+  reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
+  getFaceById(id: string): Promise<AssetFaceEntity>;
+  getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null>;
 }

+ 2 - 0
server/src/immich/app.module.ts

@@ -19,6 +19,7 @@ import {
   AssetsController,
   AuditController,
   AuthController,
+  FaceController,
   JobController,
   LibraryController,
   OAuthController,
@@ -50,6 +51,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
     APIKeyController,
     AuditController,
     AuthController,
+    FaceController,
     JobController,
     LibraryController,
     OAuthController,

+ 28 - 0
server/src/immich/controllers/face.controller.ts

@@ -0,0 +1,28 @@
+import { AssetFaceResponseDto, AuthUserDto, FaceDto, PersonResponseDto, PersonService } from '@app/domain';
+import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { AuthUser, Authenticated } from '../app.guard';
+import { UseValidation } from '../app.utils';
+import { UUIDParamDto } from './dto/uuid-param.dto';
+
+@ApiTags('Face')
+@Controller('face')
+@Authenticated()
+@UseValidation()
+export class FaceController {
+  constructor(private service: PersonService) {}
+
+  @Get()
+  getFaces(@AuthUser() authUser: AuthUserDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
+    return this.service.getFacesById(authUser, dto);
+  }
+
+  @Put(':id')
+  reassignFacesById(
+    @AuthUser() authUser: AuthUserDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: FaceDto,
+  ): Promise<PersonResponseDto> {
+    return this.service.reassignFacesById(authUser, id, dto);
+  }
+}

+ 1 - 0
server/src/immich/controllers/index.ts

@@ -5,6 +5,7 @@ export * from './app.controller';
 export * from './asset.controller';
 export * from './audit.controller';
 export * from './auth.controller';
+export * from './face.controller';
 export * from './job.controller';
 export * from './library.controller';
 export * from './oauth.controller';

+ 15 - 0
server/src/immich/controllers/person.controller.ts

@@ -1,4 +1,5 @@
 import {
+  AssetFaceUpdateDto,
   AssetResponseDto,
   AuthUserDto,
   BulkIdResponseDto,
@@ -34,6 +35,20 @@ export class PersonController {
     return this.service.getAll(authUser, withHidden);
   }
 
+  @Post()
+  createPerson(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto> {
+    return this.service.createPerson(authUser);
+  }
+
+  @Put(':id/reassign')
+  reassignFaces(
+    @AuthUser() authUser: AuthUserDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: AssetFaceUpdateDto,
+  ): Promise<PersonResponseDto[]> {
+    return this.service.reassignFaces(authUser, id, dto);
+  }
+
   @Put()
   updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
     return this.service.updatePeople(authUser, dto);

+ 2 - 3
server/src/infra/entities/person.entity.ts

@@ -8,7 +8,6 @@ import {
   UpdateDateColumn,
 } from 'typeorm';
 import { AssetFaceEntity } from './asset-face.entity';
-import { AssetEntity } from './asset.entity';
 import { UserEntity } from './user.entity';
 
 @Entity('person')
@@ -40,8 +39,8 @@ export class PersonEntity {
   @Column({ nullable: true })
   faceAssetId!: string | null;
 
-  @ManyToOne(() => AssetEntity, { onDelete: 'SET NULL', nullable: true })
-  faceAsset!: AssetEntity | null;
+  @ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true })
+  faceAsset!: AssetFaceEntity | null;
 
   @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
   faces!: AssetFaceEntity[];

+ 18 - 0
server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts

@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class EditFaceAssetForeignKey1699727044012 implements MigrationInterface {
+    name = 'EditFaceAssetForeignKey1699727044012'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`);
+        await queryRunner.query(`UPDATE person SET "faceAssetId" = asset_faces."id" FROM asset_faces WHERE person."faceAssetId" = asset_faces."assetId" AND person."id" = asset_faces."personId"`)
+        await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "asset_faces"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`);
+        await queryRunner.query(`UPDATE person SET "faceAssetId" = assets."id" FROM assets, asset_faces WHERE person."faceAssetId" = asset_faces."id" AND asset_faces."assetId" = assets."id"`);
+        await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
+    }
+
+}

+ 18 - 0
server/src/infra/repositories/access.repository.ts

@@ -5,6 +5,7 @@ import {
   ActivityEntity,
   AlbumEntity,
   AssetEntity,
+  AssetFaceEntity,
   LibraryEntity,
   PartnerEntity,
   PersonEntity,
@@ -20,6 +21,7 @@ export class AccessRepository implements IAccessRepository {
     @InjectRepository(LibraryEntity) private libraryRepository: Repository<LibraryEntity>,
     @InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
     @InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
+    @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
     @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
     @InjectRepository(UserTokenEntity) private tokenRepository: Repository<UserTokenEntity>,
   ) {}
@@ -318,6 +320,22 @@ export class AccessRepository implements IAccessRepository {
         })
         .then((persons) => new Set(persons.map((person) => person.id)));
     },
+    hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
+      if (assetFaceIds.size === 0) {
+        return new Set();
+      }
+      return this.assetFaceRepository
+        .find({
+          select: { id: true },
+          where: {
+            id: In([...assetFaceIds]),
+            asset: {
+              ownerId: userId,
+            },
+          },
+        })
+        .then((faces) => new Set(faces.map((face) => face.id)));
+    },
   };
 
   partner = {

+ 42 - 0
server/src/infra/repositories/person.repository.ts

@@ -107,6 +107,48 @@ export class PersonRepository implements IPersonRepository {
   }
 
   @GenerateSql({ params: [DummyValue.UUID] })
+  getFaces(assetId: string): Promise<AssetFaceEntity[]> {
+    return this.assetFaceRepository.find({
+      where: { assetId },
+      relations: {
+        person: true,
+      },
+    });
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID] })
+  getFaceById(id: string): Promise<AssetFaceEntity> {
+    return this.assetFaceRepository.findOneOrFail({
+      where: { id },
+      relations: {
+        person: true,
+      },
+    });
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID] })
+  getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null> {
+    return this.assetFaceRepository.findOne({
+      where: { id },
+      relations: {
+        person: true,
+        asset: true,
+      },
+    });
+  }
+
+  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
+  async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
+    const result = await this.assetFaceRepository
+      .createQueryBuilder()
+      .update()
+      .set({ personId: newPersonId })
+      .where({ id: assetFaceId })
+      .execute();
+
+    return result.affected ?? 0;
+  }
+
   getById(personId: string): Promise<PersonEntity | null> {
     return this.personRepository.findOne({ where: { id: personId } });
   }

+ 133 - 12
server/src/infra/sql/person.repository.sql

@@ -133,24 +133,145 @@ GROUP BY
 HAVING
   COUNT("face"."assetId") = 0
 
--- PersonRepository.getById
+-- PersonRepository.getFaces
 SELECT
-  "PersonEntity"."id" AS "PersonEntity_id",
-  "PersonEntity"."createdAt" AS "PersonEntity_createdAt",
-  "PersonEntity"."updatedAt" AS "PersonEntity_updatedAt",
-  "PersonEntity"."ownerId" AS "PersonEntity_ownerId",
-  "PersonEntity"."name" AS "PersonEntity_name",
-  "PersonEntity"."birthDate" AS "PersonEntity_birthDate",
-  "PersonEntity"."thumbnailPath" AS "PersonEntity_thumbnailPath",
-  "PersonEntity"."faceAssetId" AS "PersonEntity_faceAssetId",
-  "PersonEntity"."isHidden" AS "PersonEntity_isHidden"
+  "AssetFaceEntity"."id" AS "AssetFaceEntity_id",
+  "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
+  "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
+  "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
+  "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
+  "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
+  "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
+  "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
+  "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
+  "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
+  "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
+  "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
+  "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
+  "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
+  "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
+  "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
+  "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
+  "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
+  "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
 FROM
-  "person" "PersonEntity"
+  "asset_faces" "AssetFaceEntity"
+  LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
 WHERE
-  ("PersonEntity"."id" = $1)
+  ("AssetFaceEntity"."assetId" = $1)
+
+-- PersonRepository.getFaceById
+SELECT DISTINCT
+  "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id"
+FROM
+  (
+    SELECT
+      "AssetFaceEntity"."id" AS "AssetFaceEntity_id",
+      "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
+      "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
+      "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
+      "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
+      "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
+      "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
+      "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
+      "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
+      "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
+      "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
+      "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
+      "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
+      "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
+      "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
+      "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
+      "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
+      "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
+      "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden"
+    FROM
+      "asset_faces" "AssetFaceEntity"
+      LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
+    WHERE
+      ("AssetFaceEntity"."id" = $1)
+  ) "distinctAlias"
+ORDER BY
+  "AssetFaceEntity_id" ASC
 LIMIT
   1
 
+-- PersonRepository.getFaceByIdWithAssets
+SELECT DISTINCT
+  "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id"
+FROM
+  (
+    SELECT
+      "AssetFaceEntity"."id" AS "AssetFaceEntity_id",
+      "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId",
+      "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId",
+      "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding",
+      "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth",
+      "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight",
+      "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
+      "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
+      "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
+      "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
+      "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
+      "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
+      "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
+      "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId",
+      "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name",
+      "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
+      "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
+      "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
+      "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
+      "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
+      "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
+      "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
+      "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
+      "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
+      "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
+      "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
+      "AssetFaceEntity__AssetFaceEntity_asset"."resizePath" AS "AssetFaceEntity__AssetFaceEntity_asset_resizePath",
+      "AssetFaceEntity__AssetFaceEntity_asset"."webpPath" AS "AssetFaceEntity__AssetFaceEntity_asset_webpPath",
+      "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
+      "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
+      "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt",
+      "AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt",
+      "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt",
+      "AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt",
+      "AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime",
+      "AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt",
+      "AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite",
+      "AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived",
+      "AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal",
+      "AssetFaceEntity__AssetFaceEntity_asset"."isReadOnly" AS "AssetFaceEntity__AssetFaceEntity_asset_isReadOnly",
+      "AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline",
+      "AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum",
+      "AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration",
+      "AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible",
+      "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
+      "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
+      "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
+      "AssetFaceEntity__AssetFaceEntity_asset"."stackParentId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackParentId"
+    FROM
+      "asset_faces" "AssetFaceEntity"
+      LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
+      LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
+      AND (
+        "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL
+      )
+    WHERE
+      ("AssetFaceEntity"."id" = $1)
+  ) "distinctAlias"
+ORDER BY
+  "AssetFaceEntity_id" ASC
+LIMIT
+  1
+
+-- PersonRepository.reassignFace
+UPDATE "asset_faces"
+SET
+  "personId" = $1
+WHERE
+  "id" = $2
+
 -- PersonRepository.getByName
 SELECT
   "person"."id" AS "person_id",

+ 3 - 4
server/test/fixtures/person.stub.ts

@@ -1,5 +1,4 @@
 import { PersonEntity } from '@app/infra/entities';
-import { assetStub } from '@test/fixtures/asset.stub';
 import { userStub } from './user.stub';
 
 export const personStub = {
@@ -41,7 +40,7 @@ export const personStub = {
     birthDate: null,
     thumbnailPath: '/path/to/thumbnail.jpg',
     faces: [],
-    faceAssetId: null,
+    faceAssetId: 'assetFaceId',
     faceAsset: null,
     isHidden: false,
   }),
@@ -97,8 +96,8 @@ export const personStub = {
     birthDate: null,
     thumbnailPath: '/new/path/to/thumbnail.jpg',
     faces: [],
-    faceAssetId: assetStub.image.id,
-    faceAsset: assetStub.image,
+    faceAssetId: 'asset-id',
+    faceAsset: null,
     isHidden: false,
   }),
   primaryPerson: Object.freeze<PersonEntity>({

+ 1 - 0
server/test/repositories/access.repository.mock.ts

@@ -50,6 +50,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
     },
 
     person: {
+      hasFaceOwnerAccess: jest.fn(),
       checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
     },
 

+ 5 - 1
server/test/repositories/person.repository.mock.ts

@@ -20,8 +20,12 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
     getAllFaces: jest.fn(),
     getFacesByIds: jest.fn(),
     getRandomFace: jest.fn(),
-    prepareReassignFaces: jest.fn(),
+
     reassignFaces: jest.fn(),
     createFace: jest.fn(),
+    getFaces: jest.fn(),
+    reassignFace: jest.fn(),
+    getFaceById: jest.fn(),
+    getFaceByIdWithAssets: jest.fn(),
   };
 };

+ 3 - 0
web/src/api/api.ts

@@ -21,6 +21,7 @@ import {
   UserApiFp,
   AuditApi,
   ActivityApi,
+  FaceApi,
 } from './open-api';
 import { BASE_PATH } from './open-api/base';
 import { DUMMY_BASE_URL, toPathString } from './open-api/common';
@@ -33,6 +34,7 @@ class ImmichApi {
   public assetApi: AssetApi;
   public auditApi: AuditApi;
   public authenticationApi: AuthenticationApi;
+  public faceApi: FaceApi;
   public jobApi: JobApi;
   public keyApi: APIKeyApi;
   public oauthApi: OAuthApi;
@@ -60,6 +62,7 @@ class ImmichApi {
     this.libraryApi = new LibraryApi(this.config);
     this.assetApi = new AssetApi(this.config);
     this.authenticationApi = new AuthenticationApi(this.config);
+    this.faceApi = new FaceApi(this.config);
     this.jobApi = new JobApi(this.config);
     this.keyApi = new APIKeyApi(this.config);
     this.oauthApi = new OAuthApi(this.config);

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

@@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = {
 
 export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum];
 
+/**
+ * 
+ * @export
+ * @interface AssetFaceResponseDto
+ */
+export interface AssetFaceResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxX1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxX2': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxY1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'boundingBoxY2': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'imageHeight': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceResponseDto
+     */
+    'imageWidth': number;
+    /**
+     * 
+     * @type {PersonResponseDto}
+     * @memberof AssetFaceResponseDto
+     */
+    'person': PersonResponseDto | null;
+}
+/**
+ * 
+ * @export
+ * @interface AssetFaceUpdateDto
+ */
+export interface AssetFaceUpdateDto {
+    /**
+     * 
+     * @type {Array<AssetFaceUpdateItem>}
+     * @memberof AssetFaceUpdateDto
+     */
+    'data': Array<AssetFaceUpdateItem>;
+}
+/**
+ * 
+ * @export
+ * @interface AssetFaceUpdateItem
+ */
+export interface AssetFaceUpdateItem {
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceUpdateItem
+     */
+    'assetId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceUpdateItem
+     */
+    'personId': string;
+}
+/**
+ * 
+ * @export
+ * @interface AssetFaceWithoutPersonResponseDto
+ */
+export interface AssetFaceWithoutPersonResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxX1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxX2': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxY1': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'boundingBoxY2': number;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'imageHeight': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof AssetFaceWithoutPersonResponseDto
+     */
+    'imageWidth': number;
+}
 /**
  * 
  * @export
@@ -842,10 +978,10 @@ export interface AssetResponseDto {
     'ownerId': string;
     /**
      * 
-     * @type {Array<PersonResponseDto>}
+     * @type {Array<PersonWithFacesResponseDto>}
      * @memberof AssetResponseDto
      */
-    'people'?: Array<PersonResponseDto>;
+    'people'?: Array<PersonWithFacesResponseDto>;
     /**
      * 
      * @type {boolean}
@@ -1672,6 +1808,19 @@ export interface ExifResponseDto {
      */
     'timeZone'?: string | null;
 }
+/**
+ * 
+ * @export
+ * @interface FaceDto
+ */
+export interface FaceDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceDto
+     */
+    'id': string;
+}
 /**
  * 
  * @export
@@ -2564,6 +2713,49 @@ export interface PersonUpdateDto {
      */
     'name'?: string;
 }
+/**
+ * 
+ * @export
+ * @interface PersonWithFacesResponseDto
+ */
+export interface PersonWithFacesResponseDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'birthDate': string | null;
+    /**
+     * 
+     * @type {Array<AssetFaceWithoutPersonResponseDto>}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'faces': Array<AssetFaceWithoutPersonResponseDto>;
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'isHidden': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'name': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonWithFacesResponseDto
+     */
+    'thumbnailPath': string;
+}
 /**
  * 
  * @export
@@ -11349,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI {
 }
 
 
+/**
+ * FaceApi - axios parameter creator
+ * @export
+ */
+export const FaceApiAxiosParamCreator = function (configuration?: Configuration) {
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('getFaces', 'id', id)
+            const localVarPath = `/face`;
+            // 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: 'GET', ...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)
+
+            if (id !== undefined) {
+                localVarQueryParameter['id'] = id;
+            }
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {FaceDto} faceDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('reassignFacesById', 'id', id)
+            // verify required parameter 'faceDto' is not null or undefined
+            assertParamExists('reassignFacesById', 'faceDto', faceDto)
+            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: 'PUT', ...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)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+    }
+};
+
+/**
+ * FaceApi - functional programming interface
+ * @export
+ */
+export const FaceApiFp = function(configuration?: Configuration) {
+    const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration)
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetFaceResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {FaceDto} faceDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+    }
+};
+
+/**
+ * FaceApi - factory interface
+ * @export
+ */
+export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+    const localVarFp = FaceApiFp(configuration)
+    return {
+        /**
+         * 
+         * @param {FaceApiGetFacesRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetFaceResponseDto>> {
+            return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
+            return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath));
+        },
+    };
+};
+
+/**
+ * Request parameters for getFaces operation in FaceApi.
+ * @export
+ * @interface FaceApiGetFacesRequest
+ */
+export interface FaceApiGetFacesRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceApiGetFaces
+     */
+    readonly id: string
+}
+
+/**
+ * Request parameters for reassignFacesById operation in FaceApi.
+ * @export
+ * @interface FaceApiReassignFacesByIdRequest
+ */
+export interface FaceApiReassignFacesByIdRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof FaceApiReassignFacesById
+     */
+    readonly id: string
+
+    /**
+     * 
+     * @type {FaceDto}
+     * @memberof FaceApiReassignFacesById
+     */
+    readonly faceDto: FaceDto
+}
+
+/**
+ * FaceApi - object-oriented interface
+ * @export
+ * @class FaceApi
+ * @extends {BaseAPI}
+ */
+export class FaceApi extends BaseAPI {
+    /**
+     * 
+     * @param {FaceApiGetFacesRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof FaceApi
+     */
+    public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) {
+        return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof FaceApi
+     */
+    public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) {
+        return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath));
+    }
+}
+
+
 /**
  * JobApi - axios parameter creator
  * @export
@@ -13180,6 +13599,44 @@ export class PartnerApi extends BaseAPI {
  */
 export const PersonApiAxiosParamCreator = function (configuration?: Configuration) {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        createPerson: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/person`;
+            // 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: 'POST', ...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,
+            };
+        },
         /**
          * 
          * @param {boolean} [withHidden] 
@@ -13439,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {AssetFaceUpdateDto} assetFaceUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('reassignFaces', 'id', id)
+            // verify required parameter 'assetFaceUpdateDto' is not null or undefined
+            assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto)
+            const localVarPath = `/person/{id}/reassign`
+                .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: 'PUT', ...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)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetFaceUpdateDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {PeopleUpdateDto} peopleUpdateDto 
@@ -13541,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
 export const PersonApiFp = function(configuration?: Configuration) {
     const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {boolean} [withHidden] 
@@ -13602,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {AssetFaceUpdateDto} assetFaceUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {PeopleUpdateDto} peopleUpdateDto 
@@ -13633,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) {
 export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
     const localVarFp = PersonApiFp(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        createPerson(options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
+            return localVarFp.createPerson(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
@@ -13687,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
         mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
             return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
+            return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
@@ -13799,6 +14341,27 @@ export interface PersonApiMergePersonRequest {
     readonly mergePersonDto: MergePersonDto
 }
 
+/**
+ * Request parameters for reassignFaces operation in PersonApi.
+ * @export
+ * @interface PersonApiReassignFacesRequest
+ */
+export interface PersonApiReassignFacesRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonApiReassignFaces
+     */
+    readonly id: string
+
+    /**
+     * 
+     * @type {AssetFaceUpdateDto}
+     * @memberof PersonApiReassignFaces
+     */
+    readonly assetFaceUpdateDto: AssetFaceUpdateDto
+}
+
 /**
  * Request parameters for updatePeople operation in PersonApi.
  * @export
@@ -13841,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest {
  * @extends {BaseAPI}
  */
 export class PersonApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof PersonApi
+     */
+    public createPerson(options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
@@ -13907,6 +14480,17 @@ export class PersonApi extends BaseAPI {
         return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof PersonApi
+     */
+    public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.

+ 1 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -560,7 +560,7 @@
 
 <section
   id="immich-asset-viewer"
-  class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
+  class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
 >
   <!-- Top navigation bar -->
   {#if $slideshowState === SlideshowState.None}

+ 95 - 46
web/src/lib/components/asset-viewer/detail-panel.svelte

@@ -15,16 +15,18 @@
     mdiCalendar,
     mdiCameraIris,
     mdiClose,
-    mdiPencil,
+    mdiEye,
+    mdiEyeOff,
     mdiImageOutline,
     mdiMapMarkerOutline,
     mdiInformationOutline,
-    mdiEye,
-    mdiEyeOff,
+    mdiPencil,
   } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
+  import PersonSidePanel from '../faces-page/person-side-panel.svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
   import Map from '../shared-components/map/map.svelte';
+  import { boundingBoxesArray } from '$lib/stores/people.store';
   import { websocketStore } from '$lib/stores/websocket';
   import { AppRoute } from '$lib/constants';
   import ChangeLocation from '../shared-components/change-location.svelte';
@@ -35,8 +37,21 @@
   export let albums: AlbumResponseDto[] = [];
   export let albumId: string | null = null;
 
+  let showAssetPath = false;
   let textarea: HTMLTextAreaElement;
   let description: string;
+  let showEditFaces = false;
+  let previousId: string;
+
+  $: {
+    if (!previousId) {
+      previousId = asset.id;
+    }
+    if (asset.id !== previousId) {
+      showEditFaces = false;
+      previousId = asset.id;
+    }
+  }
 
   $: isOwner = $page?.data?.user?.id === asset.ownerId;
 
@@ -84,6 +99,14 @@
     return undefined;
   };
 
+  const handleRefreshPeople = async () => {
+    await api.assetApi.getAssetById({ id: asset.id }).then((res) => {
+      people = res.data?.people || [];
+      textarea.value = res.data?.exifInfo?.description || '';
+    });
+    showEditFaces = false;
+  };
+
   const autoGrowHeight = (e: Event) => {
     const target = e.target as HTMLTextAreaElement;
     target.style.height = 'auto';
@@ -106,7 +129,6 @@
     }
   };
 
-  let showAssetPath = false;
   const toggleAssetPath = () => (showAssetPath = !showAssetPath);
 
   let isShowChangeDate = false;
@@ -139,7 +161,7 @@
   }
 </script>
 
-<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
+<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
   <div class="flex place-items-center gap-2">
     <button
       class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
@@ -183,54 +205,71 @@
     <section class="px-4 py-4 text-sm">
       <div class="flex h-10 w-full items-center justify-between">
         <h2>PEOPLE</h2>
-        {#if people.some((person) => person.isHidden)}
+        <div class="flex gap-2">
+          {#if people.some((person) => person.isHidden)}
+            <CircleIconButton
+              title="Show hidden people"
+              icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
+              padding="1"
+              on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
+            />
+          {/if}
           <CircleIconButton
-            title="Show hidden people"
-            icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
+            title="Edit people"
+            icon={mdiPencil}
             padding="1"
-            on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
+            size="20"
+            on:click={() => (showEditFaces = true)}
           />
-        {/if}
+        </div>
       </div>
 
       <div class="mt-2 flex flex-wrap gap-2">
-        {#each people as person (person.id)}
-          <a
-            href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
-            class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
-            on:click={() => dispatch('close-viewer')}
+        {#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>
+            <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>
     </section>
@@ -589,3 +628,13 @@
     {/each}
   </section>
 {/if}
+
+{#if showEditFaces}
+  <PersonSidePanel
+    assetId={asset.id}
+    on:close={() => {
+      showEditFaces = false;
+    }}
+    on:refresh={handleRefreshPeople}
+  />
+{/if}

+ 19 - 7
web/src/lib/components/asset-viewer/photo-viewer.svelte

@@ -8,6 +8,9 @@
   import { photoZoomState } from '$lib/stores/zoom-image.store';
   import { isWebCompatibleImage } from '$lib/utils/asset-utils';
   import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
+  import { photoViewer } from '$lib/stores/assets.store';
+  import { getBoundingBox } from '$lib/utils/people-utils';
+  import { boundingBoxesArray } from '$lib/stores/people.store';
 
   export let asset: AssetResponseDto;
   export let element: HTMLDivElement | undefined = undefined;
@@ -20,6 +23,13 @@
   let copyImageToClipboard: (src: string) => Promise<Blob>;
   let canCopyImagesToClipboard: () => boolean;
 
+  $: if (imgElement) {
+    createZoomImageWheel(imgElement, {
+      maxZoom: 10,
+      wheelZoomRatio: 0.2,
+    });
+  }
+
   onMount(async () => {
     // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
     // TODO: Move to regular import once the package correctly supports ESM.
@@ -29,6 +39,7 @@
   });
 
   onDestroy(() => {
+    $boundingBoxesArray = [];
     abortController?.abort();
   });
 
@@ -105,16 +116,10 @@
 
     if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed) {
       hasZoomed = true;
+
       loadAssetData({ loadOriginal: true });
     }
   });
-
-  $: if (imgElement) {
-    createZoomImageWheel(imgElement, {
-      maxZoom: 10,
-      wheelZoomRatio: 0.2,
-    });
-  }
 </script>
 
 <svelte:window on:keydown={handleKeypress} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
@@ -129,12 +134,19 @@
   {:then}
     <div bind:this={imgElement} class="h-full w-full">
       <img
+        bind:this={$photoViewer}
         transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
         src={assetData}
         alt={asset.id}
         class="h-full w-full object-contain"
         draggable="false"
       />
+      {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
+        <div
+          class="absolute border-solid border-white border-[3px] rounded-lg p-3"
+          style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
+        />
+      {/each}
     </div>
   {/await}
 </div>

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

@@ -52,7 +52,7 @@
 
 {#if hidden}
   <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
-    <Icon path={mdiEyeOffOutline} size="2em" class="text-{eyeColor}" />
+    <Icon {title} path={mdiEyeOffOutline} size="2em" class="text-{eyeColor}" />
   </div>
 {/if}
 

+ 1 - 1
web/src/lib/components/elements/icon.svelte

@@ -4,7 +4,7 @@
   export let size: string | number = '1em';
   export let color = 'currentColor';
   export let path: string;
-  export let title = '';
+  export let title: string | null = null;
   export let desc = '';
   export let flipped = false;
   let className = '';

+ 246 - 0
web/src/lib/components/faces-page/assign-face-side-panel.svelte

@@ -0,0 +1,246 @@
+<script lang="ts">
+  import { api, type AssetFaceResponseDto, type PersonResponseDto } from '@api';
+  import { createEventDispatcher } from 'svelte';
+  import { linear } from 'svelte/easing';
+  import { fly } from 'svelte/transition';
+  import Icon from '../elements/icon.svelte';
+  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 { handleError } from '$lib/utils/handle-error';
+  import { photoViewer } from '$lib/stores/assets.store';
+
+  export let peopleWithFaces: AssetFaceResponseDto[];
+  export let allPeople: PersonResponseDto[];
+  export let editedPersonIndex: number;
+
+  // loading spinners
+  let isShowLoadingNewPerson = false;
+  let isShowLoadingSearch = false;
+
+  // search people
+  let searchedPeople: PersonResponseDto[] = [];
+  let searchedPeopleCopy: PersonResponseDto[] = [];
+  let searchWord: string;
+  let searchFaces = false;
+  let searchName = '';
+
+  const dispatch = createEventDispatcher();
+  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);
+
+    clearTimeout(timeout);
+    isShowLoadingNewPerson = false;
+    dispatch('createPerson', newFeaturePhoto);
+  };
+
+  const searchPeople = async () => {
+    if ((searchedPeople.length < 20 && searchName.startsWith(searchWord)) || searchName === '') {
+      return;
+    }
+    const timeout = setTimeout(() => (isShowLoadingSearch = true), 100);
+    try {
+      const { data } = await api.searchApi.searchPerson({ name: searchName });
+      searchedPeople = data;
+      searchedPeopleCopy = data;
+      searchWord = searchName;
+    } catch (error) {
+      handleError(error, "Can't search people");
+    } finally {
+      clearTimeout(timeout);
+    }
+
+    isShowLoadingSearch = false;
+  };
+
+  $: {
+    searchedPeople = searchNameLocal(searchName, searchedPeopleCopy, 10);
+  }
+
+  const initInput = (element: HTMLInputElement) => {
+    element.focus();
+  };
+</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">
+    {#if !searchFaces}
+      <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">Select face</p>
+      </div>
+      <div class="flex justify-end gap-2">
+        <button
+          class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
+          title="Search existing person"
+          on:click={() => {
+            searchFaces = true;
+          }}
+        >
+          <div>
+            <Icon path={mdiMagnify} size="24" />
+          </div>
+        </button>
+        {#if !isShowLoadingNewPerson}
+          <button
+            class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
+            on:click={handleCreatePerson}
+            title="Create new person"
+          >
+            <div>
+              <Icon path={mdiPlus} size="24" />
+            </div>
+          </button>
+        {:else}
+          <div class="flex place-content-center place-items-center">
+            <LoadingSpinner />
+          </div>
+        {/if}
+      </div>
+    {:else}
+      <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>
+      <div class="w-full flex">
+        <input
+          class="w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg"
+          type="text"
+          placeholder="Name or nickname"
+          bind:value={searchName}
+          on:input={searchPeople}
+          use:initInput
+        />
+        {#if isShowLoadingSearch}
+          <div>
+            <LoadingSpinner />
+          </div>
+        {/if}
+      </div>
+      <button
+        class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
+        on:click={() => (searchFaces = false)}
+      >
+        <div>
+          <Icon path={mdiClose} size="24" />
+        </div>
+      </button>
+    {/if}
+  </div>
+  <div class="px-4 py-4 text-sm">
+    <h2 class="mb-8 mt-4 uppercase">All people</h2>
+    <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}
+            <div class="w-fit">
+              <button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
+                <div class="relative">
+                  <ImageThumbnail
+                    curve
+                    shadow
+                    url={api.getPeopleThumbnailUrl(person.id)}
+                    altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
+                    title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
+                    widthStyle="90px"
+                    heightStyle="90px"
+                    thumbhash={null}
+                    hidden={person.isHidden}
+                  />
+                </div>
+
+                <p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
+                  {person.name}
+                </p>
+              </button>
+            </div>
+          {/if}
+        {/each}
+      {:else}
+        {#each searchedPeople as person (person.id)}
+          {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
+            <div class="w-fit">
+              <button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
+                <div class="relative">
+                  <ImageThumbnail
+                    curve
+                    shadow
+                    url={api.getPeopleThumbnailUrl(person.id)}
+                    altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
+                    title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
+                    widthStyle="90px"
+                    heightStyle="90px"
+                    thumbhash={null}
+                    hidden={person.isHidden}
+                  />
+                </div>
+                <p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
+              </button>
+            </div>
+          {/if}
+        {/each}
+      {/if}
+    </div>
+  </div>
+</section>

+ 16 - 85
web/src/lib/components/faces-page/merge-face-selector.svelte

@@ -12,23 +12,18 @@
   import { handleError } from '$lib/utils/handle-error';
   import { goto } from '$app/navigation';
   import { AppRoute } from '$lib/constants';
-  import { mdiCallMerge, mdiClose, mdiMagnify, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
+  import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
   import Icon from '$lib/components/elements/icon.svelte';
   import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
-  import { cloneDeep } from 'lodash-es';
-  import LoadingSpinner from '../shared-components/loading-spinner.svelte';
-  import { searchNameLocal } from '$lib/utils/person';
+  import PeopleList from './people-list.svelte';
   import { page } from '$app/stores';
 
   export let person: PersonResponseDto;
   let people: PersonResponseDto[] = [];
-  let peopleCopy: PersonResponseDto[] = [];
   let selectedPeople: PersonResponseDto[] = [];
   let screenHeight: number;
   let isShowConfirmation = false;
-  let name = '';
-  let searchWord: string;
-  let isSearchingPeople = false;
+
   let dispatch = createEventDispatcher();
 
   $: hasSelection = selectedPeople.length > 0;
@@ -39,44 +34,12 @@
   onMount(async () => {
     const { data } = await api.personApi.getAllPeople({ withHidden: false });
     people = data.people;
-    peopleCopy = cloneDeep(people);
   });
 
   const onClose = () => {
     dispatch('go-back');
   };
 
-  const resetSearch = () => {
-    name = '';
-    people = peopleCopy;
-  };
-
-  const searchPeople = async (force: boolean) => {
-    if (name === '') {
-      people = peopleCopy;
-      return;
-    }
-    if (!force) {
-      if (people.length < 20 && name.startsWith(searchWord)) {
-        people = searchNameLocal(name, peopleCopy, 10);
-        return;
-      }
-    }
-
-    const timeout = setTimeout(() => (isSearchingPeople = true), 100);
-    try {
-      const { data } = await api.searchApi.searchPerson({ name });
-      people = data;
-      searchWord = name;
-    } catch (error) {
-      handleError(error, "Can't search people");
-    } finally {
-      clearTimeout(timeout);
-    }
-
-    isSearchingPeople = false;
-  };
-
   const handleSwapPeople = () => {
     [person, selectedPeople[0]] = [selectedPeople[0], person];
     $page.url.searchParams.set('action', 'merge');
@@ -113,7 +76,7 @@
       });
       dispatch('merge');
     } catch (error) {
-      handleError(error, 'Cannot merge faces');
+      handleError(error, 'Cannot merge people');
     } finally {
       isShowConfirmation = false;
     }
@@ -131,7 +94,7 @@
       {#if hasSelection}
         Selected {selectedPeople.length}
       {:else}
-        Merge faces
+        Merge people
       {/if}
       <div />
     </svelte:fragment>
@@ -151,7 +114,7 @@
   <section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
     <section id="merge-face-selector relative">
       <div class="mb-10 h-[200px] place-content-center place-items-center">
-        <p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to merge</p>
+        <p class="mb-4 text-center uppercase dark:text-white">Choose matching people to merge</p>
 
         <div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
           {#each selectedPeople as person (person.id)}
@@ -178,57 +141,25 @@
         </div>
       </div>
 
-      <div
-        class="flex w-40 sm:w-48 md:w-96 h-14 rounded-lg bg-gray-100 p-2 dark:bg-gray-700 mb-8 gap-2 place-items-center"
-      >
-        <button on:click={() => searchPeople(true)}>
-          <div class="w-fit">
-            <Icon path={mdiMagnify} size="24" />
-          </div>
-        </button>
-        <!-- svelte-ignore a11y-autofocus -->
-        <input
-          autofocus
-          class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
-          type="text"
-          placeholder="Search names"
-          bind:value={name}
-          on:input={() => searchPeople(false)}
-        />
-        {#if name}
-          <button on:click={resetSearch}>
-            <Icon path={mdiClose} />
-          </button>
-        {/if}
-        {#if isSearchingPeople}
-          <div class="flex place-items-center">
-            <LoadingSpinner />
-          </div>
-        {/if}
-      </div>
-
-      <div
-        class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 pt-8 px-8 pb-10 dark:bg-immich-dark-gray"
-        style:max-height={screenHeight - 250 - 250 + 'px'}
-      >
-        <div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
-          {#each unselectedPeople as person (person.id)}
-            <FaceThumbnail {person} on:click={() => onSelect(person)} circle border selectable />
-          {/each}
-        </div>
-      </div>
+      <PeopleList
+        people={unselectedPeople}
+        peopleCopy={unselectedPeople}
+        unselectedPeople={selectedPeople}
+        {screenHeight}
+        on:select={({ detail }) => onSelect(detail)}
+      />
     </section>
 
     {#if isShowConfirmation}
       <ConfirmDialogue
-        title="Merge faces"
+        title="Merge people"
         confirmText="Merge"
         on:confirm={handleMerge}
         on:cancel={() => (isShowConfirmation = false)}
       >
         <svelte:fragment slot="prompt">
-          <p>Are you sure you want merge these faces? <br />This action is <strong>irreversible</strong>.</p>
-        </svelte:fragment>
+          <p>Are you sure you want merge these people ?</p></svelte:fragment
+        >
       </ConfirmDialogue>
     {/if}
   </section>

+ 2 - 2
web/src/lib/components/faces-page/merge-suggestion-modal.svelte

@@ -36,7 +36,7 @@
     >
       <div class="relative flex items-center justify-between">
         <h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
-          Merge faces - {title}
+          Merge People - {title}
         </h1>
         <div class="p-2">
           <CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
@@ -108,7 +108,7 @@
       </div>
 
       <div class="flex px-4 md:px-8 md:pt-4">
-        <h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
+        <h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
       </div>
       <div class="flex px-4 pt-2 md:px-8">
         <p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>

+ 5 - 5
web/src/lib/components/faces-page/people-card.svelte

@@ -14,12 +14,12 @@
   export let person: PersonResponseDto;
   export let preload = false;
 
-  type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-faces' | 'hide-face';
+  type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person';
   let dispatch = createEventDispatcher<{
     'change-name': void;
     'set-birth-date': void;
-    'merge-faces': void;
-    'hide-face': void;
+    'merge-people': void;
+    'hide-person': void;
   }>();
 
   let showVerticalDots = false;
@@ -82,10 +82,10 @@
 {#if showContextMenu}
   <Portal target="body">
     <ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
-      <MenuOption on:click={() => onMenuClick('hide-face')} text="Hide face" />
+      <MenuOption on:click={() => onMenuClick('hide-person')} text="Hide Person" />
       <MenuOption on:click={() => onMenuClick('change-name')} text="Change name" />
       <MenuOption on:click={() => onMenuClick('set-birth-date')} text="Set date of birth" />
-      <MenuOption on:click={() => onMenuClick('merge-faces')} text="Merge faces" />
+      <MenuOption on:click={() => onMenuClick('merge-people')} text="Merge People" />
     </ContextMenu>
   </Portal>
 {/if}

+ 106 - 0
web/src/lib/components/faces-page/people-list.svelte

@@ -0,0 +1,106 @@
+<script lang="ts">
+  import { api, type PersonResponseDto } from '@api';
+  import FaceThumbnail from './face-thumbnail.svelte';
+  import { createEventDispatcher } from 'svelte';
+  import Icon from '../elements/icon.svelte';
+  import { mdiClose, mdiMagnify } from '@mdi/js';
+  import { handleError } from '$lib/utils/handle-error';
+  import LoadingSpinner from '../shared-components/loading-spinner.svelte';
+  import { searchNameLocal } from '$lib/utils/person';
+
+  export let screenHeight: number;
+  export let people: PersonResponseDto[];
+  export let peopleCopy: PersonResponseDto[];
+  export let unselectedPeople: PersonResponseDto[];
+
+  let name = '';
+  let searchWord: string;
+  let isSearchingPeople = false;
+
+  let dispatch = createEventDispatcher<{
+    select: PersonResponseDto;
+  }>();
+
+  const resetSearch = () => {
+    name = '';
+    people = peopleCopy;
+  };
+
+  $: {
+    people = peopleCopy.filter(
+      (person) => !unselectedPeople.some((unselectedPerson) => unselectedPerson.id === person.id),
+    );
+    people = searchNameLocal(name, people, 10);
+  }
+
+  const searchPeople = async (force: boolean) => {
+    if (name === '') {
+      people = peopleCopy;
+      return;
+    }
+    if (!force) {
+      if (people.length < 20 && name.startsWith(searchWord)) {
+        return;
+      }
+    }
+
+    const timeout = setTimeout(() => (isSearchingPeople = true), 100);
+    try {
+      const { data } = await api.searchApi.searchPerson({ name });
+      people = data;
+      searchWord = name;
+    } catch (error) {
+      handleError(error, "Can't search people");
+    } finally {
+      clearTimeout(timeout);
+    }
+
+    isSearchingPeople = false;
+  };
+</script>
+
+<div class="flex w-40 sm:w-48 md:w-96 h-14 rounded-lg bg-gray-100 p-2 dark:bg-gray-700 mb-8 gap-2 place-items-center">
+  <button on:click={() => searchPeople(true)}>
+    <div class="w-fit">
+      <Icon path={mdiMagnify} size="24" />
+    </div>
+  </button>
+  <!-- svelte-ignore a11y-autofocus -->
+  <input
+    autofocus
+    class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
+    type="text"
+    placeholder="Search names"
+    bind:value={name}
+    on:input={() => searchPeople(false)}
+  />
+  {#if name}
+    <button on:click={resetSearch}>
+      <Icon path={mdiClose} />
+    </button>
+  {/if}
+  {#if isSearchingPeople}
+    <div class="flex place-items-center">
+      <LoadingSpinner />
+    </div>
+  {/if}
+</div>
+
+<div
+  class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
+  style:max-height={screenHeight - 400 + 'px'}
+>
+  <div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
+    {#each people as person (person.id)}
+      <FaceThumbnail
+        {person}
+        on:click={() => {
+          dispatch('select', person);
+        }}
+        circle
+        border
+        selectable
+      />
+    {/each}
+  </div>
+</div>

+ 278 - 0
web/src/lib/components/faces-page/person-side-panel.svelte

@@ -0,0 +1,278 @@
+<script lang="ts">
+  import { fly } from 'svelte/transition';
+  import { linear } from 'svelte/easing';
+  import { api, type PersonResponseDto, AssetFaceResponseDto } from '@api';
+  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  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 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';
+
+  export let assetId: string;
+
+  // keep track of the changes
+  let numberOfPersonToCreate: string[] = [];
+  let numberOfAssetFaceGenerated: string[] = [];
+
+  // faces
+  let peopleWithFaces: AssetFaceResponseDto[] = [];
+  let selectedPersonToReassign: (PersonResponseDto | null)[];
+  let selectedPersonToCreate: (string | null)[];
+  let editedPersonIndex: number;
+
+  // loading spinners
+  let isShowLoadingDone = false;
+  let isShowLoadingPeople = false;
+
+  // search people
+  let showSeletecFaces = false;
+  let allPeople: PersonResponseDto[] = [];
+
+  // timers
+  let loaderLoadingDoneTimeout: NodeJS.Timeout;
+  let automaticRefreshTimeout: NodeJS.Timeout;
+
+  const { onPersonThumbnail } = websocketStore;
+  const dispatch = createEventDispatcher();
+
+  // Reset value
+  $onPersonThumbnail = '';
+
+  $: {
+    if ($onPersonThumbnail) {
+      numberOfAssetFaceGenerated.push($onPersonThumbnail);
+      if (
+        isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
+        loaderLoadingDoneTimeout &&
+        automaticRefreshTimeout &&
+        selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
+      ) {
+        clearTimeout(loaderLoadingDoneTimeout);
+        clearTimeout(automaticRefreshTimeout);
+        dispatch('refresh');
+      }
+    }
+  }
+
+  onMount(async () => {
+    const timeout = setTimeout(() => (isShowLoadingPeople = true), 100);
+    try {
+      const { data } = await api.personApi.getAllPeople({ withHidden: true });
+      allPeople = data.people;
+      const result = await api.faceApi.getFaces({ id: assetId });
+      peopleWithFaces = result.data;
+      selectedPersonToCreate = new Array<string | null>(peopleWithFaces.length);
+      selectedPersonToReassign = new Array<PersonResponseDto | null>(peopleWithFaces.length);
+    } catch (error) {
+      handleError(error, "Can't get faces");
+    } finally {
+      clearTimeout(timeout);
+    }
+    isShowLoadingPeople = false;
+  });
+
+  const isEqual = (a: string[], b: string[]): boolean => {
+    return b.every((valueB) => a.includes(valueB));
+  };
+
+  const handleBackButton = () => {
+    dispatch('close');
+  };
+
+  const handleReset = (index: number) => {
+    if (selectedPersonToReassign[index]) {
+      selectedPersonToReassign[index] = null;
+    }
+    if (selectedPersonToCreate[index]) {
+      selectedPersonToCreate[index] = null;
+    }
+  };
+
+  const handleEditFaces = async () => {
+    loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), 100);
+    const numberOfChanges =
+      selectedPersonToCreate.filter((person) => person !== null).length +
+      selectedPersonToReassign.filter((person) => person !== null).length;
+    if (numberOfChanges > 0) {
+      try {
+        for (let i = 0; i < peopleWithFaces.length; i++) {
+          const personId = selectedPersonToReassign[i]?.id;
+
+          if (personId) {
+            await api.faceApi.reassignFacesById({
+              id: personId,
+              faceDto: { id: peopleWithFaces[i].id },
+            });
+          } else if (selectedPersonToCreate[i]) {
+            const { data } = await api.personApi.createPerson();
+            numberOfPersonToCreate.push(data.id);
+            await api.faceApi.reassignFacesById({
+              id: data.id,
+              faceDto: { id: peopleWithFaces[i].id },
+            });
+          }
+        }
+
+        notificationController.show({
+          message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
+          type: NotificationType.Info,
+        });
+      } catch (error) {
+        handleError(error, "Can't apply changes");
+      }
+    }
+
+    isShowLoadingDone = false;
+    if (numberOfPersonToCreate.length === 0) {
+      clearTimeout(loaderLoadingDoneTimeout);
+      dispatch('refresh');
+    } else {
+      automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15000);
+    }
+  };
+
+  const handleCreatePerson = (newFeaturePhoto: string | null) => {
+    const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
+    if (newFeaturePhoto && personToUpdate) {
+      selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
+    }
+    showSeletecFaces = false;
+  };
+
+  const handleReassignFace = (person: PersonResponseDto | null) => {
+    if (person) {
+      selectedPersonToReassign[editedPersonIndex] = person;
+      showSeletecFaces = false;
+    }
+  };
+
+  const handlePersonPicker = async (index: number) => {
+    editedPersonIndex = index;
+    showSeletecFaces = true;
+  };
+</script>
+
+<section
+  transition:fly={{ x: 360, duration: 100, easing: linear }}
+  class="absolute top-0 z-[2000] 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">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>
+    {:else}
+      <LoadingSpinner />
+    {/if}
+  </div>
+
+  <div class="px-4 py-4 text-sm">
+    <div class="mt-4 flex flex-wrap gap-2">
+      {#if isShowLoadingPeople}
+        <div class="flex w-full justify-center">
+          <LoadingSpinner />
+        </div>
+      {:else}
+        {#each peopleWithFaces as face, index}
+          {#if face.person}
+            <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 = [])}
+              >
+                <div class="relative">
+                  <ImageThumbnail
+                    curve
+                    shadow
+                    url={selectedPersonToCreate[index] ||
+                      api.getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
+                    altText={selectedPersonToReassign[index]
+                      ? selectedPersonToReassign[index]?.name || ''
+                      : selectedPersonToCreate[index]
+                        ? 'new person'
+                        : getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
+                    title={selectedPersonToReassign[index]
+                      ? selectedPersonToReassign[index]?.name || ''
+                      : selectedPersonToCreate[index]
+                        ? 'new person'
+                        : getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
+                    widthStyle="90px"
+                    heightStyle="90px"
+                    thumbhash={null}
+                    hidden={selectedPersonToReassign[index]
+                      ? selectedPersonToReassign[index]?.isHidden
+                      : selectedPersonToCreate[index]
+                        ? false
+                        : face.person?.isHidden}
+                  />
+                </div>
+                {#if !selectedPersonToCreate[index]}
+                  <p class="relative mt-1 truncate font-medium" title={face.person?.name}>
+                    {#if selectedPersonToReassign[index]?.id}
+                      {selectedPersonToReassign[index]?.name}
+                    {:else}
+                      {face.person?.name}
+                    {/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} />
+                        </div>
+                      </div>
+                    </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>
+              </div>
+            </div>
+          {/if}
+        {/each}
+      {/if}
+    </div>
+  </div>
+</section>
+
+{#if showSeletecFaces}
+  <AssignFaceSidePanel
+    {peopleWithFaces}
+    {allPeople}
+    {editedPersonIndex}
+    on:close={() => (showSeletecFaces = false)}
+    on:createPerson={(event) => handleCreatePerson(event.detail)}
+    on:reassign={(event) => handleReassignFace(event.detail)}
+  />
+{/if}

+ 2 - 2
web/src/lib/components/faces-page/show-hide.svelte

@@ -22,12 +22,12 @@
   >
     <div class="flex items-center">
       <CircleIconButton icon={mdiClose} on:click={() => dispatch('closeClick')} />
-      <p class="ml-4 hidden sm:block">Show & hide faces</p>
+      <p class="ml-4 hidden sm:block">Show & hide people</p>
     </div>
     <div class="flex items-center justify-end">
       <div class="flex items-center md:mr-8">
         <CircleIconButton
-          title="Reset faces visibility"
+          title="Reset people visibility"
           icon={mdiRestart}
           on:click={() => dispatch('reset-visibility')}
         />

+ 190 - 0
web/src/lib/components/faces-page/unmerge-face-selector.svelte

@@ -0,0 +1,190 @@
+<script lang="ts">
+  import { createEventDispatcher, onMount } from 'svelte';
+  import FaceThumbnail from './face-thumbnail.svelte';
+  import { quintOut } from 'svelte/easing';
+  import { fly } from 'svelte/transition';
+  import { api, AssetFaceUpdateItem, type PersonResponseDto } from '@api';
+  import ControlAppBar from '../shared-components/control-app-bar.svelte';
+  import Button from '../elements/buttons/button.svelte';
+  import { mdiPlus, mdiMerge } from '@mdi/js';
+  import LoadingSpinner from '../shared-components/loading-spinner.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { notificationController, NotificationType } from '../shared-components/notification/notification';
+  import PeopleList from './people-list.svelte';
+  import Icon from '$lib/components/elements/icon.svelte';
+
+  export let assetIds: string[];
+  export let personAssets: PersonResponseDto;
+
+  let people: PersonResponseDto[] = [];
+  let selectedPerson: PersonResponseDto | null = null;
+  let disableButtons = false;
+  let showLoadingSpinnerCreate = false;
+  let showLoadingSpinnerReassign = false;
+  let hasSelection = false;
+  let screenHeight: number;
+
+  $: unselectedPeople = selectedPerson
+    ? people.filter((person) => selectedPerson && person.id !== selectedPerson.id && personAssets.id !== person.id)
+    : people;
+
+  let dispatch = createEventDispatcher();
+
+  const selectedPeople: AssetFaceUpdateItem[] = [];
+
+  for (const assetId of assetIds) {
+    selectedPeople.push({ assetId, personId: personAssets.id });
+  }
+
+  onMount(async () => {
+    const { data } = await api.personApi.getAllPeople({ withHidden: false });
+    people = data.people;
+  });
+
+  const onClose = () => {
+    dispatch('close');
+  };
+
+  const handleSelectedPerson = (person: PersonResponseDto) => {
+    if (selectedPerson && selectedPerson.id === person.id) {
+      handleRemoveSelectedPerson();
+      return;
+    }
+    selectedPerson = person;
+    hasSelection = true;
+  };
+
+  const handleRemoveSelectedPerson = () => {
+    selectedPerson = null;
+    hasSelection = false;
+  };
+
+  const handleCreate = async () => {
+    const timeout = setTimeout(() => (showLoadingSpinnerCreate = true), 100);
+
+    try {
+      disableButtons = true;
+      const { data } = await api.personApi.createPerson();
+      await api.personApi.reassignFaces({
+        id: data.id,
+        assetFaceUpdateDto: { data: selectedPeople },
+      });
+
+      notificationController.show({
+        message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to a new person`,
+        type: NotificationType.Info,
+      });
+    } catch (error) {
+      handleError(error, 'Unable to reassign assets to a new person');
+    } finally {
+      clearTimeout(timeout);
+    }
+
+    showLoadingSpinnerCreate = false;
+    dispatch('confirm');
+  };
+
+  const handleReassign = async () => {
+    const timeout = setTimeout(() => (showLoadingSpinnerReassign = true), 100);
+    try {
+      disableButtons = true;
+      if (selectedPerson) {
+        await api.personApi.reassignFaces({
+          id: selectedPerson.id,
+          assetFaceUpdateDto: { data: selectedPeople },
+        });
+        notificationController.show({
+          message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to ${
+            selectedPerson.name || 'an existing person'
+          }`,
+          type: NotificationType.Info,
+        });
+      }
+    } catch (error) {
+      handleError(error, `Unable to reassign assets to ${selectedPerson?.name || 'an existing person'}`);
+    } finally {
+      clearTimeout(timeout);
+    }
+
+    showLoadingSpinnerReassign = false;
+    dispatch('confirm');
+  };
+</script>
+
+<svelte:window bind:innerHeight={screenHeight} />
+
+<section
+  transition:fly={{ y: 500, duration: 100, easing: quintOut }}
+  class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
+>
+  <ControlAppBar on:close-button-click={onClose}>
+    <svelte:fragment slot="leading">
+      <slot name="header" />
+      <div />
+    </svelte:fragment>
+    <svelte:fragment slot="trailing">
+      <div class="flex gap-4">
+        <Button
+          title={'Assign selected assets to a new person'}
+          size={'sm'}
+          disabled={disableButtons || hasSelection}
+          on:click={() => {
+            handleCreate();
+          }}
+        >
+          {#if !showLoadingSpinnerCreate}
+            <Icon path={mdiPlus} size={18} />
+          {:else}
+            <LoadingSpinner />
+          {/if}
+          <span class="ml-2"> Create new Person</span></Button
+        >
+        <Button
+          size={'sm'}
+          title={'Assign selected assets to an existing person'}
+          disabled={disableButtons || !hasSelection}
+          on:click={() => {
+            handleReassign();
+          }}
+        >
+          {#if !showLoadingSpinnerReassign}
+            <div>
+              <Icon path={mdiMerge} size={18} class="rotate-180" />
+            </div>
+          {:else}
+            <LoadingSpinner />
+          {/if}
+          <span class="ml-2"> Reassign</span></Button
+        >
+      </div>
+    </svelte:fragment>
+  </ControlAppBar>
+  <slot name="merge" />
+  <section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
+    <section id="merge-face-selector relative">
+      {#if selectedPerson !== null}
+        <div class="mb-10 h-[200px] place-content-center place-items-center">
+          <p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to re assign</p>
+
+          <div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
+            <FaceThumbnail
+              person={selectedPerson}
+              border
+              circle
+              selectable
+              thumbnailSize={180}
+              on:click={handleRemoveSelectedPerson}
+            />
+          </div>
+        </div>
+      {/if}
+      <PeopleList
+        people={unselectedPeople}
+        peopleCopy={unselectedPeople}
+        unselectedPeople={selectedPerson ? [selectedPerson, personAssets] : [personAssets]}
+        {screenHeight}
+        on:select={({ detail }) => handleSelectedPerson(detail)}
+      />
+    </section>
+  </section>
+</section>

+ 2 - 0
web/src/lib/stores/assets.store.ts

@@ -58,6 +58,8 @@ interface TrashAsset {
   value: string;
 }
 
+export const photoViewer = writable<HTMLImageElement | null>(null);
+
 type PendingChange = AddAsset | DeleteAsset | TrashAsset;
 
 export class AssetStore {

+ 12 - 0
web/src/lib/stores/people.store.ts

@@ -0,0 +1,12 @@
+import { writable } from 'svelte/store';
+
+export interface Faces {
+  imageHeight: number;
+  imageWidth: number;
+  boundingBoxX1: number;
+  boundingBoxX2: number;
+  boundingBoxY1: number;
+  boundingBoxY2: number;
+}
+
+export const boundingBoxesArray = writable<Faces[]>([]);

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

@@ -0,0 +1,71 @@
+import type { Faces } from '$lib/stores/people.store';
+import type { ZoomImageWheelState } from '@zoom-image/core';
+
+const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
+  const ratio = img.naturalWidth / img.naturalHeight;
+  let width = img.height * ratio;
+  let height = img.height;
+  if (width > img.width) {
+    width = img.width;
+    height = img.width / ratio;
+  }
+  return { width, height };
+};
+
+export interface boundingBox {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+}
+
+export const getBoundingBox = (
+  faces: Faces[],
+  zoom: ZoomImageWheelState,
+  photoViewer: HTMLImageElement | null,
+): boundingBox[] => {
+  const boxes: boundingBox[] = [];
+
+  if (photoViewer === null) {
+    return boxes;
+  }
+  const clientHeight = photoViewer.clientHeight;
+  const clientWidth = photoViewer.clientWidth;
+
+  const { width, height } = getContainedSize(photoViewer);
+
+  for (const face of faces) {
+    /*
+     *
+     * Create the coordinates of the box based on the displayed image.
+     * The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer.
+     *
+     */
+    const coordinates = {
+      x1:
+        (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 +
+        ((clientWidth - width) / 2) * zoom.currentZoom +
+        zoom.currentPositionX,
+      x2:
+        (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 +
+        ((clientWidth - width) / 2) * zoom.currentZoom +
+        zoom.currentPositionX,
+      y1:
+        (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 +
+        ((clientHeight - height) / 2) * zoom.currentZoom +
+        zoom.currentPositionY,
+      y2:
+        (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 +
+        ((clientHeight - height) / 2) * zoom.currentZoom +
+        zoom.currentPositionY,
+    };
+
+    boxes.push({
+      top: Math.round(coordinates.y1),
+      left: Math.round(coordinates.x1),
+      width: Math.round(coordinates.x2 - coordinates.x1),
+      height: Math.round(coordinates.y2 - coordinates.y1),
+    });
+  }
+  return boxes;
+};

+ 4 - 0
web/src/lib/utils/person.ts

@@ -30,3 +30,7 @@ export const searchNameLocal = (
         })
         .slice(0, slice);
 };
+
+export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
+  return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
+};

+ 10 - 10
web/src/routes/(user)/people/+page.svelte

@@ -79,7 +79,7 @@
     // trigger reactivity
     people = people;
 
-    // Reset variables used on the "Show & hide faces"   modal
+    // Reset variables used on the "Show & hide people"   modal
     showLoadingSpinner = false;
     selectHidden = false;
     toggleVisibility = false;
@@ -145,13 +145,13 @@
         `Unable to change the visibility for ${changed.length} ${changed.length <= 1 ? 'person' : 'people'}`,
       );
     }
-    // Reset variables used on the "Show & hide faces" modal
+    // Reset variables used on the "Show & hide people" modal
     showLoadingSpinner = false;
     selectHidden = false;
     toggleVisibility = false;
   };
 
-  const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
+  const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => {
     const [personToMerge, personToBeMergedIn] = response;
     showMergeModal = false;
 
@@ -167,7 +167,7 @@
       people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
 
       notificationController.show({
-        message: 'Merge faces succesfully',
+        message: 'Merge people succesfully',
         type: NotificationType.Info,
       });
     } catch (error) {
@@ -213,7 +213,7 @@
     edittingPerson = detail;
   };
 
-  const handleHideFace = async (detail: PersonResponseDto) => {
+  const handleHidePerson = async (detail: PersonResponseDto) => {
     try {
       const { data: updatedPerson } = await api.personApi.updatePerson({
         id: detail.id,
@@ -244,7 +244,7 @@
     }
   };
 
-  const handleMergeFaces = (detail: PersonResponseDto) => {
+  const handleMergePeople = (detail: PersonResponseDto) => {
     goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge&previousRoute=${AppRoute.PEOPLE}`);
   };
 
@@ -352,7 +352,7 @@
       {potentialMergePeople}
       on:close={() => (showMergeModal = false)}
       on:reject={() => changeName()}
-      on:confirm={(event) => handleMergeSameFace(event.detail)}
+      on:confirm={(event) => handleMergeSamePerson(event.detail)}
     />
   </FullScreenModal>
 {/if}
@@ -363,7 +363,7 @@
       <IconButton on:click={() => (selectHidden = !selectHidden)}>
         <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
           <Icon path={mdiEyeOutline} size="18" />
-          <p class="ml-2">Show & hide faces</p>
+          <p class="ml-2">Show & hide people</p>
         </div>
       </IconButton>
     {/if}
@@ -379,8 +379,8 @@
               preload={idx < 20}
               on:change-name={() => handleChangeName(person)}
               on:set-birth-date={() => handleSetBirthDate(person)}
-              on:merge-faces={() => handleMergeFaces(person)}
-              on:hide-face={() => handleHideFace(person)}
+              on:merge-people={() => handleMergePeople(person)}
+              on:hide-person={() => handleHidePerson(person)}
             />
           {/if}
         {/each}

+ 41 - 16
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -4,6 +4,7 @@
   import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
   import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
   import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
+  import UnMergeFaceSelector from '$lib/components/faces-page/unmerge-face-selector.svelte';
   import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
   import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
   import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
@@ -46,10 +47,11 @@
 
   enum ViewMode {
     VIEW_ASSETS = 'view-assets',
-    SELECT_FACE = 'select-face',
-    MERGE_FACES = 'merge-faces',
+    SELECT_PERSON = 'select-person',
+    MERGE_PEOPLE = 'merge-people',
     SUGGEST_MERGE = 'suggest-merge',
     BIRTH_DATE = 'birth-date',
+    UNASSIGN_ASSETS = 'unassign-faces',
   }
 
   let assetStore = new AssetStore({
@@ -124,7 +126,7 @@
       previousRoute = getPreviousRoute;
     }
     if (action == 'merge') {
-      viewMode = ViewMode.MERGE_FACES;
+      viewMode = ViewMode.MERGE_PEOPLE;
     }
   });
   const handleEscape = () => {
@@ -155,7 +157,17 @@
     }
   });
 
-  const toggleHideFace = async () => {
+  const handleUnmerge = () => {
+    $assetStore.removeAssets(Array.from($selectedAssets).map((a) => a.id));
+    assetInteractionStore.clearMultiselect();
+    viewMode = ViewMode.VIEW_ASSETS;
+  };
+
+  const handleReassignAssets = () => {
+    viewMode = ViewMode.UNASSIGN_ASSETS;
+  };
+
+  const toggleHidePerson = async () => {
     try {
       await api.personApi.updatePerson({
         id: data.person.id,
@@ -179,7 +191,7 @@
   };
 
   const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => {
-    if (viewMode !== ViewMode.SELECT_FACE) {
+    if (viewMode !== ViewMode.SELECT_PERSON) {
       return;
     }
 
@@ -202,7 +214,7 @@
     }
   };
 
-  const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
+  const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => {
     const [personToMerge, personToBeMergedIn] = response;
     viewMode = ViewMode.VIEW_ASSETS;
     isEditingName = false;
@@ -212,7 +224,7 @@
         mergePersonDto: { ids: [personToMerge.id] },
       });
       notificationController.show({
-        message: 'Merge faces succesfully',
+        message: 'Merge people succesfully',
         type: NotificationType.Info,
       });
       people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
@@ -333,6 +345,15 @@
   };
 </script>
 
+{#if viewMode === ViewMode.UNASSIGN_ASSETS}
+  <UnMergeFaceSelector
+    assetIds={Array.from($selectedAssets).map((a) => a.id)}
+    personAssets={data.person}
+    on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
+    on:confirm={handleUnmerge}
+  />
+{/if}
+
 {#if viewMode === ViewMode.SUGGEST_MERGE}
   <MergeSuggestionModal
     {personMerge1}
@@ -340,7 +361,7 @@
     {potentialMergePeople}
     on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
     on:reject={() => changeName()}
-    on:confirm={(event) => handleMergeSameFace(event.detail)}
+    on:confirm={(event) => handleMergeSamePerson(event.detail)}
   />
 {/if}
 
@@ -352,7 +373,7 @@
   />
 {/if}
 
-{#if viewMode === ViewMode.MERGE_FACES}
+{#if viewMode === ViewMode.MERGE_PEOPLE}
   <MergeFaceSelector person={data.person} on:go-back={handleGoBack} on:merge={handleMerge} />
 {/if}
 
@@ -370,6 +391,7 @@
         <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
         <FavoriteAction menuItem removeFavorite={isAllFavorite} />
         <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
+        <MenuOption text="Fix incorrect match" on:click={handleReassignAssets} />
         <ChangeDate menuItem />
         <ChangeLocation menuItem />
       </AssetSelectContextMenu>
@@ -379,16 +401,19 @@
       <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close-button-click={() => goto(previousRoute)}>
         <svelte:fragment slot="trailing">
           <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
-            <MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_FACE)} />
+            <MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_PERSON)} />
             <MenuOption text="Set date of birth" on:click={() => (viewMode = ViewMode.BIRTH_DATE)} />
-            <MenuOption text="Merge face" on:click={() => (viewMode = ViewMode.MERGE_FACES)} />
-            <MenuOption text={data.person.isHidden ? 'Unhide face' : 'Hide face'} on:click={() => toggleHideFace()} />
+            <MenuOption text="Merge person" on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)} />
+            <MenuOption
+              text={data.person.isHidden ? 'Unhide person' : 'Hide person'}
+              on:click={() => toggleHidePerson()}
+            />
           </AssetSelectContextMenu>
         </svelte:fragment>
       </ControlAppBar>
     {/if}
 
-    {#if viewMode === ViewMode.SELECT_FACE}
+    {#if viewMode === ViewMode.SELECT_PERSON}
       <ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
         <svelte:fragment slot="leading">Select feature photo</svelte:fragment>
       </ControlAppBar>
@@ -401,13 +426,13 @@
     <AssetGrid
       {assetStore}
       {assetInteractionStore}
-      isSelectionMode={viewMode === ViewMode.SELECT_FACE}
-      singleSelect={viewMode === ViewMode.SELECT_FACE}
+      isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
+      singleSelect={viewMode === ViewMode.SELECT_PERSON}
       on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)}
       on:escape={handleEscape}
     >
       {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
-        <!-- Face information block -->
+        <!-- Person information block -->
         <div
           role="button"
           class="relative w-fit p-4 sm:px-6"