From e23168810bbe1c418343f73bddbf46bac33e65ba Mon Sep 17 00:00:00 2001 From: martabal <74269598+martabal@users.noreply.github.com> Date: Tue, 5 Dec 2023 23:47:19 +0100 Subject: [PATCH] feat(web,server): unassign faces --- cli/src/api/open-api/api.ts | 109 +++++- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 2 + mobile/openapi/doc/AssetResponseDto.md | 2 +- mobile/openapi/doc/FaceApi.md | 56 ++++ .../openapi/doc/PeopleWithFacesResponseDto.md | 16 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/face_api.dart | 48 +++ mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/asset_response_dto.dart | 12 +- .../model/people_with_faces_response_dto.dart | 106 ++++++ .../openapi/test/asset_response_dto_test.dart | 2 +- mobile/openapi/test/face_api_test.dart | 5 + .../people_with_faces_response_dto_test.dart | 32 ++ server/immich-openapi-specs.json | 68 +++- .../asset/response-dto/asset-response.dto.ts | 10 +- server/src/domain/person/person.dto.ts | 6 + .../src/domain/person/person.service.spec.ts | 15 + server/src/domain/person/person.service.ts | 15 + .../domain/repositories/person.repository.ts | 4 +- .../src/immich/api-v1/asset/asset.service.ts | 2 +- .../src/immich/controllers/face.controller.ts | 7 +- server/test/fixtures/face.stub.ts | 14 + server/test/fixtures/shared-link.stub.ts | 2 +- web/src/api/open-api/api.ts | 109 +++++- .../asset-viewer/detail-panel.svelte | 107 +++--- .../assets/thumbnail/image-thumbnail.svelte | 3 +- .../faces-page/assign-face-side-panel.svelte | 53 +-- .../faces-page/person-side-panel.svelte | 309 +++++++++++++++--- .../unassigned-faces-side-pannel.svelte | 120 +++++++ web/src/lib/utils/people-utils.ts | 5 + web/src/lib/utils/person.ts | 43 ++- 32 files changed, 1119 insertions(+), 169 deletions(-) create mode 100644 mobile/openapi/doc/PeopleWithFacesResponseDto.md create mode 100644 mobile/openapi/lib/model/people_with_faces_response_dto.dart create mode 100644 mobile/openapi/test/people_with_faces_response_dto_test.dart create mode 100644 web/src/lib/components/faces-page/unassigned-faces-side-pannel.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index f8b8188ac9..f0e6549342 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -978,10 +978,10 @@ export interface AssetResponseDto { 'ownerId': string; /** * - * @type {Array} + * @type {PeopleWithFacesResponseDto} * @memberof AssetResponseDto */ - 'people'?: Array; + 'people'?: PeopleWithFacesResponseDto | null; /** * * @type {boolean} @@ -2632,6 +2632,25 @@ export interface PeopleUpdateItem { */ 'name'?: string; } +/** + * + * @export + * @interface PeopleWithFacesResponseDto + */ +export interface PeopleWithFacesResponseDto { + /** + * + * @type {number} + * @memberof PeopleWithFacesResponseDto + */ + 'numberOfAssets': number; + /** + * + * @type {Array} + * @memberof PeopleWithFacesResponseDto + */ + 'people': Array; +} /** * * @export @@ -11635,6 +11654,48 @@ export const FaceApiAxiosParamCreator = function (configuration?: Configuration) localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration) + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unassignFace: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('unassignFace', 'id', id) + const localVarPath = `/face/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -11671,6 +11732,16 @@ export const FaceApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async unassignFace(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.unassignFace(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -11699,6 +11770,15 @@ export const FaceApiFactory = function (configuration?: Configuration, basePath? reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.unassignFace(requestParameters.id, options).then((request) => request(axios, basePath)); + }, }; }; @@ -11737,6 +11817,20 @@ export interface FaceApiReassignFacesByIdRequest { readonly faceDto: FaceDto } +/** + * Request parameters for unassignFace operation in FaceApi. + * @export + * @interface FaceApiUnassignFaceRequest + */ +export interface FaceApiUnassignFaceRequest { + /** + * + * @type {string} + * @memberof FaceApiUnassignFace + */ + readonly id: string +} + /** * FaceApi - object-oriented interface * @export @@ -11765,6 +11859,17 @@ export class FaceApi extends BaseAPI { public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) { return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).unassignFace(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 1d72f22499..dc8c8578c1 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -102,6 +102,7 @@ doc/PathType.md doc/PeopleResponseDto.md doc/PeopleUpdateDto.md doc/PeopleUpdateItem.md +doc/PeopleWithFacesResponseDto.md doc/PersonApi.md doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md @@ -292,6 +293,7 @@ lib/model/path_type.dart lib/model/people_response_dto.dart lib/model/people_update_dto.dart lib/model/people_update_item.dart +lib/model/people_with_faces_response_dto.dart lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart @@ -459,6 +461,7 @@ test/path_type_test.dart test/people_response_dto_test.dart test/people_update_dto_test.dart test/people_update_item_test.dart +test/people_with_faces_response_dto_test.dart test/person_api_test.dart test/person_response_dto_test.dart test/person_statistics_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7eb7f7f56a..227087778d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -135,6 +135,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | *FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | +*FaceApi* | [**unassignFace**](doc//FaceApi.md#unassignface) | **DELETE** /face/{id} | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library | @@ -299,6 +300,7 @@ Class | Method | HTTP request | Description - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) + - [PeopleWithFacesResponseDto](doc//PeopleWithFacesResponseDto.md) - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 7c79f74183..bc9c9919f7 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -30,7 +30,7 @@ Name | Type | Description | Notes **originalPath** | **String** | | **owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional] **ownerId** | **String** | | -**people** | [**List**](PersonWithFacesResponseDto.md) | | [optional] [default to const []] +**people** | [**PeopleWithFacesResponseDto**](PeopleWithFacesResponseDto.md) | | [optional] **resized** | **bool** | | **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] **stack** | [**List**](AssetResponseDto.md) | | [optional] [default to const []] diff --git a/mobile/openapi/doc/FaceApi.md b/mobile/openapi/doc/FaceApi.md index 84793a5c90..a58e2a029b 100644 --- a/mobile/openapi/doc/FaceApi.md +++ b/mobile/openapi/doc/FaceApi.md @@ -11,6 +11,7 @@ Method | HTTP request | Description ------------- | ------------- | ------------- [**getFaces**](FaceApi.md#getfaces) | **GET** /face | [**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | +[**unassignFace**](FaceApi.md#unassignface) | **DELETE** /face/{id} | # **getFaces** @@ -125,3 +126,58 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **unassignFace** +> AssetFaceResponseDto unassignFace(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = FaceApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.unassignFace(id); + print(result); +} catch (e) { + print('Exception when calling FaceApi->unassignFace: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**AssetFaceResponseDto**](AssetFaceResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/doc/PeopleWithFacesResponseDto.md b/mobile/openapi/doc/PeopleWithFacesResponseDto.md new file mode 100644 index 0000000000..02d4a57e70 --- /dev/null +++ b/mobile/openapi/doc/PeopleWithFacesResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.PeopleWithFacesResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**numberOfAssets** | **int** | | +**people** | [**List**](PersonWithFacesResponseDto.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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c0caf20e4e..c0100e936b 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -135,6 +135,7 @@ part 'model/path_type.dart'; part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; +part 'model/people_with_faces_response_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; diff --git a/mobile/openapi/lib/api/face_api.dart b/mobile/openapi/lib/api/face_api.dart index bce6814071..f88965815f 100644 --- a/mobile/openapi/lib/api/face_api.dart +++ b/mobile/openapi/lib/api/face_api.dart @@ -119,4 +119,52 @@ class FaceApi { } return null; } + + /// Performs an HTTP 'DELETE /face/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future unassignFaceWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/face/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future unassignFace(String id,) async { + final response = await unassignFaceWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFaceResponseDto',) as AssetFaceResponseDto; + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7d376949e7..1a36740b38 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -357,6 +357,8 @@ class ApiClient { return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': return PeopleUpdateItem.fromJson(value); + case 'PeopleWithFacesResponseDto': + return PeopleWithFacesResponseDto.fromJson(value); case 'PersonResponseDto': return PersonResponseDto.fromJson(value); case 'PersonStatisticsResponseDto': diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index bb7330d3ec..50b61f53ae 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -35,7 +35,7 @@ class AssetResponseDto { required this.originalPath, this.owner, required this.ownerId, - this.people = const [], + this.people, required this.resized, this.smartInfo, this.stack = const [], @@ -104,7 +104,7 @@ class AssetResponseDto { String ownerId; - List people; + PeopleWithFacesResponseDto? people; bool resized; @@ -190,7 +190,7 @@ class AssetResponseDto { (originalPath.hashCode) + (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + - (people.hashCode) + + (people == null ? 0 : people!.hashCode) + (resized.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + (stack.hashCode) + @@ -240,7 +240,11 @@ class AssetResponseDto { // json[r'owner'] = null; } json[r'ownerId'] = this.ownerId; + if (this.people != null) { json[r'people'] = this.people; + } else { + // json[r'people'] = null; + } json[r'resized'] = this.resized; if (this.smartInfo != null) { json[r'smartInfo'] = this.smartInfo; @@ -299,7 +303,7 @@ class AssetResponseDto { originalPath: mapValueOfType(json, r'originalPath')!, owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, - people: PersonWithFacesResponseDto.listFromJson(json[r'people']), + people: PeopleWithFacesResponseDto.fromJson(json[r'people']), resized: mapValueOfType(json, r'resized')!, smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), stack: AssetResponseDto.listFromJson(json[r'stack']), diff --git a/mobile/openapi/lib/model/people_with_faces_response_dto.dart b/mobile/openapi/lib/model/people_with_faces_response_dto.dart new file mode 100644 index 0000000000..05a2f1ebfc --- /dev/null +++ b/mobile/openapi/lib/model/people_with_faces_response_dto.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PeopleWithFacesResponseDto { + /// Returns a new [PeopleWithFacesResponseDto] instance. + PeopleWithFacesResponseDto({ + required this.numberOfAssets, + this.people = const [], + }); + + int numberOfAssets; + + List people; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleWithFacesResponseDto && + other.numberOfAssets == numberOfAssets && + other.people == people; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (numberOfAssets.hashCode) + + (people.hashCode); + + @override + String toString() => 'PeopleWithFacesResponseDto[numberOfAssets=$numberOfAssets, people=$people]'; + + Map toJson() { + final json = {}; + json[r'numberOfAssets'] = this.numberOfAssets; + json[r'people'] = this.people; + return json; + } + + /// Returns a new [PeopleWithFacesResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleWithFacesResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PeopleWithFacesResponseDto( + numberOfAssets: mapValueOfType(json, r'numberOfAssets')!, + people: PersonWithFacesResponseDto.listFromJson(json[r'people']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PeopleWithFacesResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PeopleWithFacesResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleWithFacesResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PeopleWithFacesResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'numberOfAssets', + 'people', + }; +} + diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index b8a64b6a24..8f35b4024a 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -127,7 +127,7 @@ void main() { // TODO }); - // List people (default value: const []) + // PeopleWithFacesResponseDto people test('to test the property `people`', () async { // TODO }); diff --git a/mobile/openapi/test/face_api_test.dart b/mobile/openapi/test/face_api_test.dart index 3bd4d982f4..55c289e041 100644 --- a/mobile/openapi/test/face_api_test.dart +++ b/mobile/openapi/test/face_api_test.dart @@ -27,5 +27,10 @@ void main() { // TODO }); + //Future unassignFace(String id) async + test('test unassignFace', () async { + // TODO + }); + }); } diff --git a/mobile/openapi/test/people_with_faces_response_dto_test.dart b/mobile/openapi/test/people_with_faces_response_dto_test.dart new file mode 100644 index 0000000000..03530c9d20 --- /dev/null +++ b/mobile/openapi/test/people_with_faces_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for PeopleWithFacesResponseDto +void main() { + // final instance = PeopleWithFacesResponseDto(); + + group('test PeopleWithFacesResponseDto', () { + // int numberOfAssets + test('to test the property `numberOfAssets`', () async { + // TODO + }); + + // List people (default value: const []) + test('to test the property `people`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index d6355e8944..822af190af 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3266,6 +3266,46 @@ } }, "/face/{id}": { + "delete": { + "operationId": "unassignFace", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Face" + ] + }, "put": { "operationId": "reassignFacesById", "parameters": [ @@ -7012,10 +7052,12 @@ "type": "string" }, "people": { - "items": { - "$ref": "#/components/schemas/PersonWithFacesResponseDto" - }, - "type": "array" + "allOf": [ + { + "$ref": "#/components/schemas/PeopleWithFacesResponseDto" + } + ], + "nullable": true }, "resized": { "type": "boolean" @@ -8390,6 +8432,24 @@ ], "type": "object" }, + "PeopleWithFacesResponseDto": { + "properties": { + "numberOfAssets": { + "type": "integer" + }, + "people": { + "items": { + "$ref": "#/components/schemas/PersonWithFacesResponseDto" + }, + "type": "array" + } + }, + "required": [ + "numberOfAssets", + "people" + ], + "type": "object" + }, "PersonResponseDto": { "properties": { "birthDate": { diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index c3a7491cfb..ed12c40d73 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -1,6 +1,6 @@ import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { PersonWithFacesResponseDto } from '../../person/person.dto'; +import { PeopleWithFacesResponseDto, PersonWithFacesResponseDto } from '../../person/person.dto'; import { TagResponseDto, mapTag } from '../../tag'; import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto'; import { ExifResponseDto, mapExif } from './exif-response.dto'; @@ -39,7 +39,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; tags?: TagResponseDto[]; - people?: PersonWithFacesResponseDto[]; + people?: PeopleWithFacesResponseDto | null; /**base64 encoded sha1 hash */ checksum!: string; stackParentId?: string | null; @@ -53,7 +53,7 @@ export type AssetMapOptions = { withStack?: boolean; }; -const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { +const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto => { const result: PersonWithFacesResponseDto[] = []; if (faces) { faces.forEach((face) => { @@ -68,7 +68,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] }); } - return result; + return { people: result, numberOfAssets: faces.length }; }; export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { @@ -114,7 +114,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map(mapTag), - people: peopleWithFaces(entity.faces), + people: entity.faces ? peopleWithFaces(entity.faces) : null, checksum: entity.checksum.toString('base64'), stackParentId: entity.stackParentId, stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index ed46933939..5ebc01df79 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -78,6 +78,12 @@ export class PersonWithFacesResponseDto extends PersonResponseDto { faces!: AssetFaceWithoutPersonResponseDto[]; } +export class PeopleWithFacesResponseDto { + people!: PersonWithFacesResponseDto[]; + @ApiProperty({ type: 'integer' }) + numberOfAssets!: number; +} + export class AssetFaceWithoutPersonResponseDto { @ValidateUUID() id!: string; diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 0ce15f5aec..cb84891ea8 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -491,6 +491,21 @@ describe(PersonService.name, () => { }); }); + describe('unassignFace', () => { + it('should unassign a face', async () => { + personMock.getFaceById.mockResolvedValue(faceStub.face1); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.reassignFace.mockResolvedValue(1); + personMock.getRandomFace.mockResolvedValue(null); + personMock.getFaceById.mockResolvedValue(faceStub.unassignedFace); + + await expect(sut.unassignFace(authStub.admin, faceStub.face1.id)).resolves.toStrictEqual( + mapFaces(faceStub.unassignedFace, authStub.admin), + ); + }); + }); + describe('handlePersonDelete', () => { it('should stop if a person has not be found', async () => { personMock.getById.mockResolvedValue(null); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 79fdcbafe6..1f689851c8 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -117,6 +117,21 @@ export class PersonService { return result; } + async unassignFace(authUser: AuthUserDto, id: string): Promise { + let face = await this.repository.getFaceById(id); + await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id); + if (face.personId) { + await this.access.requirePermission(authUser, Permission.PERSON_WRITE, face.personId); + } + + await this.repository.reassignFace(face.id, null); + if (face.person && face.person.faceAssetId === face.id) { + await this.createNewFeaturePhoto([face.person.id]); + } + face = await this.repository.getFaceById(id); + return mapFaces(face, authUser); + } + async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise { await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId); diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 4c6dcdc9c9..1746cb8d7c 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -18,7 +18,7 @@ export interface AssetFaceId { export interface UpdateFacesData { oldPersonId: string; - newPersonId: string; + newPersonId: string | null; } export interface PersonStatistics { @@ -49,7 +49,7 @@ export interface IPersonRepository { getRandomFace(personId: string): Promise; createFace(entity: Partial): Promise; getFaces(assetId: string): Promise; - reassignFace(assetFaceId: string, newPersonId: string): Promise; + reassignFace(assetFaceId: string, newPersonId: string | null): Promise; getFaceById(id: string): Promise; getFaceByIdWithAssets(id: string): Promise; } diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 48b64672d8..6cc7c921e9 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -133,7 +133,7 @@ export class AssetService { const data = mapAsset(asset, { withStack: true }); if (data.ownerId !== authUser.id) { - data.people = []; + data.people = null; } if (authUser.isPublicUser) { diff --git a/server/src/immich/controllers/face.controller.ts b/server/src/immich/controllers/face.controller.ts index 5fd2dff276..5b5b559a84 100644 --- a/server/src/immich/controllers/face.controller.ts +++ b/server/src/immich/controllers/face.controller.ts @@ -1,5 +1,5 @@ import { AssetFaceResponseDto, AuthUserDto, FaceDto, PersonResponseDto, PersonService } from '@app/domain'; -import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthUser, Authenticated } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -25,4 +25,9 @@ export class FaceController { ): Promise { return this.service.reassignFacesById(authUser, id, dto); } + + @Delete(':id') + unassignFace(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.unassignFace(authUser, id); + } } diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index d009ddadce..40d52ab629 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -17,6 +17,20 @@ export const faceStub = { imageHeight: 1024, imageWidth: 1024, }), + unassignedFace: Object.freeze({ + id: 'assetFaceId', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: null, + person: null, + embedding: [1, 2, 3, 4], + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + }), primaryFace1: Object.freeze({ id: 'assetFaceId', assetId: assetStub.image.id, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 56a0c10450..6890be94df 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -67,7 +67,7 @@ const assetResponse: AssetResponseDto = { exifInfo: assetInfo, livePhotoVideoId: null, tags: [], - people: [], + people: null, checksum: 'ZmlsZSBoYXNo', isTrashed: false, libraryId: 'library-id', diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f8b8188ac9..f0e6549342 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -978,10 +978,10 @@ export interface AssetResponseDto { 'ownerId': string; /** * - * @type {Array} + * @type {PeopleWithFacesResponseDto} * @memberof AssetResponseDto */ - 'people'?: Array; + 'people'?: PeopleWithFacesResponseDto | null; /** * * @type {boolean} @@ -2632,6 +2632,25 @@ export interface PeopleUpdateItem { */ 'name'?: string; } +/** + * + * @export + * @interface PeopleWithFacesResponseDto + */ +export interface PeopleWithFacesResponseDto { + /** + * + * @type {number} + * @memberof PeopleWithFacesResponseDto + */ + 'numberOfAssets': number; + /** + * + * @type {Array} + * @memberof PeopleWithFacesResponseDto + */ + 'people': Array; +} /** * * @export @@ -11635,6 +11654,48 @@ export const FaceApiAxiosParamCreator = function (configuration?: Configuration) localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration) + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unassignFace: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('unassignFace', 'id', id) + const localVarPath = `/face/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -11671,6 +11732,16 @@ export const FaceApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async unassignFace(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.unassignFace(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -11699,6 +11770,15 @@ export const FaceApiFactory = function (configuration?: Configuration, basePath? reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.unassignFace(requestParameters.id, options).then((request) => request(axios, basePath)); + }, }; }; @@ -11737,6 +11817,20 @@ export interface FaceApiReassignFacesByIdRequest { readonly faceDto: FaceDto } +/** + * Request parameters for unassignFace operation in FaceApi. + * @export + * @interface FaceApiUnassignFaceRequest + */ +export interface FaceApiUnassignFaceRequest { + /** + * + * @type {string} + * @memberof FaceApiUnassignFace + */ + readonly id: string +} + /** * FaceApi - object-oriented interface * @export @@ -11765,6 +11859,17 @@ export class FaceApi extends BaseAPI { public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) { return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {FaceApiUnassignFaceRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public unassignFace(requestParameters: FaceApiUnassignFaceRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).unassignFace(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index aa36f9195e..452152adac 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -59,7 +59,7 @@ // Get latest description from server if (asset.id && !api.isSharedLink) { api.assetApi.getAssetById({ id: asset.id }).then((res) => { - people = res.data?.people || []; + people = res.data?.people?.people || []; textarea.value = res.data?.exifInfo?.description || ''; }); } @@ -74,7 +74,8 @@ } })(); - $: people = asset.people || []; + $: people = asset.people?.people || []; + $: numberOfFaces = asset.people?.numberOfAssets || 0; $: showingHiddenPeople = false; const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => { @@ -101,7 +102,8 @@ const handleRefreshPeople = async () => { await api.assetApi.getAssetById({ id: asset.id }).then((res) => { - people = res.data?.people || []; + people = res.data?.people?.people || []; + numberOfFaces = asset.people?.numberOfAssets || 0; textarea.value = res.data?.exifInfo?.description || ''; }); showEditFaces = false; @@ -201,7 +203,7 @@ /> - {#if !api.isSharedLink && people.length > 0} + {#if !api.isSharedLink && numberOfFaces > 0}

PEOPLE

@@ -223,55 +225,56 @@ />
- - + {/if}
{/if} diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 1be7e8ad21..a217d7021c 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -19,6 +19,7 @@ export let border = false; export let preload = true; export let eyeColor: 'black' | 'white' = 'white'; + export let persistentBorder = false; let complete = false; let img: HTMLImageElement; @@ -42,7 +43,7 @@ {title} class="object-cover transition duration-300 {border ? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary' - : ''}" + : ''} {persistentBorder ? 'border-[3px] border-immich-dark-primary/80 border-immich-primary' : ''}" class:rounded-xl={curve} class:shadow-lg={shadow} class:rounded-full={circle} diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index 9b391e9389..6fbb377324 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -7,13 +7,12 @@ import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; - import { getPersonNameWithHiddenValue, searchNameLocal } from '$lib/utils/person'; + import { getPersonNameWithHiddenValue, searchNameLocal, zoomImageToBase64 } from '$lib/utils/person'; import { handleError } from '$lib/utils/handle-error'; import { photoViewer } from '$lib/stores/assets.store'; - export let peopleWithFaces: AssetFaceResponseDto[]; + export let personWithFace: AssetFaceResponseDto; export let allPeople: PersonResponseDto[]; - export let editedPersonIndex: number; // loading spinners let isShowLoadingNewPerson = false; @@ -30,51 +29,11 @@ const handleBackButton = () => { dispatch('close'); }; - const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise => { - if ($photoViewer === null) { - return null; - } - const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2 } = face; - - const coordinates = { - x1: ($photoViewer.naturalWidth / face.imageWidth) * x1, - x2: ($photoViewer.naturalWidth / face.imageWidth) * x2, - y1: ($photoViewer.naturalHeight / face.imageHeight) * y1, - y2: ($photoViewer.naturalHeight / face.imageHeight) * y2, - }; - - const faceWidth = coordinates.x2 - coordinates.x1; - const faceHeight = coordinates.y2 - coordinates.y1; - - const faceImage = new Image(); - faceImage.src = $photoViewer.src; - - await new Promise((resolve) => { - faceImage.onload = resolve; - faceImage.onerror = () => resolve(null); - }); - - const canvas = document.createElement('canvas'); - canvas.width = faceWidth; - canvas.height = faceHeight; - - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight); - - return canvas.toDataURL(); - } else { - return null; - } - }; const handleCreatePerson = async () => { const timeout = setTimeout(() => (isShowLoadingNewPerson = true), 100); - const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id); - const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null; - - dispatch('createPerson', newFeaturePhoto); + const newFeaturePhoto = await zoomImageToBase64(personWithFace, $photoViewer); clearTimeout(timeout); isShowLoadingNewPerson = false; @@ -111,7 +70,7 @@
{#if !searchFaces} @@ -193,7 +152,7 @@
{#if searchName == ''} {#each allPeople as person (person.id)} - {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} + {#if person.id !== personWithFace.person?.id}
-

Edit faces

+

+ {isSelectingFaces ? 'Select Faces' : 'Edit faces'} +

{#if !isShowLoadingDone} - +
+ {#if !isSelectingFaces && unassignedFaces.length > 0} + + {/if} + {#if !peopleWithFaces.every((item) => item.person === null)} + + {/if} + {#if !isSelectingFaces} + + {/if} +
{:else} {/if}
+
+ {#if peopleWithFaces.every((item) => item.person === null)} +
+
+ +

No faces visible

+
+
+ {:else} +
Visible faces
+ {/if} + + {#if isSelectingFaces && selectedPersonToRemove && selectedPersonToRemove.filter((value) => value).length > 0} + + {/if} +
{#if isShowLoadingPeople}
@@ -203,6 +359,8 @@ on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])} on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} on:mouseleave={() => ($boundingBoxesArray = [])} + on:click={() => handleSelectFace(index)} + on:keydown={() => handleSelectFace(index)} >
{#if !selectedPersonToCreate[index]} @@ -239,40 +400,100 @@ {/if}

{/if} - -
- {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]} -
- - {:else} - - {/if} -
+ + {:else} + + {/if} +
+ {/if}
{/if} {/each} {/if} + {#if selectedPersonToAdd.length > 0} + Faces To add +
+ {#each selectedPersonToAdd as face, index} + {#if face} +
+
($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseleave={() => ($boundingBoxesArray = [])} + on:click={() => handleSelectFace(index)} + on:keydown={() => handleSelectFace(index)} + > +
+ +
+ {#if face.person?.name} +

+ {face.person?.name} +

{/if} + {#if !isSelectingFaces} +
+ +
+ {/if} +
+
+ {/if} + {/each} +
+ {/if}
{#if showSeletecFaces} (showSeletecFaces = false)} on:createPerson={(event) => handleCreatePerson(event.detail)} on:reassign={(event) => handleReassignFace(event.detail)} /> {/if} + +{#if showUnassignedFaces} + (showUnassignedFaces = false)} + on:createPerson={(event) => handleCreatePersonFromUnassignedFace(event.detail)} + on:reassign={(event) => handleReassignFaceFromUnassignedFace(event.detail)} + /> +{/if} diff --git a/web/src/lib/components/faces-page/unassigned-faces-side-pannel.svelte b/web/src/lib/components/faces-page/unassigned-faces-side-pannel.svelte new file mode 100644 index 0000000000..833fb11136 --- /dev/null +++ b/web/src/lib/components/faces-page/unassigned-faces-side-pannel.svelte @@ -0,0 +1,120 @@ + + +
+
+
+ +

Faces Available

+
+
+ {#if unassignedFaces.length > 0} +
+
+ {#each unassignedFaces as face, index} + {#if face && !selectedPersonToAdd.some((faceToAdd) => face && faceToAdd.id === face.id)} +
+ +
+ {/if} + {/each} +
+
+ {:else} +
+ +

No faces available

+
+ {/if} +
+ +{#if showSeletecFaces} + (showSeletecFaces = false)} + on:createPerson={(event) => handleCreatePerson(event.detail)} + on:reassign={(event) => handleReassignFace(event.detail)} + /> +{/if} diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index 1d630c8c32..f1e70c55f7 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -1,4 +1,5 @@ import type { Faces } from '$lib/stores/people.store'; +import type { AssetFaceResponseDto } from '@api'; import type { ZoomImageWheelState } from '@zoom-image/core'; const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { @@ -19,6 +20,10 @@ export interface boundingBox { height: number; } +export interface FaceWithGeneretedThumbnail extends AssetFaceResponseDto { + customThumbnail: string; +} + export const getBoundingBox = ( faces: Faces[], zoom: ZoomImageWheelState, diff --git a/web/src/lib/utils/person.ts b/web/src/lib/utils/person.ts index e26d6c6936..9d5d5303d5 100644 --- a/web/src/lib/utils/person.ts +++ b/web/src/lib/utils/person.ts @@ -1,4 +1,4 @@ -import type { PersonResponseDto } from '@api'; +import type { AssetFaceResponseDto, PersonResponseDto } from '@api'; export const searchNameLocal = ( name: string, @@ -34,3 +34,44 @@ export const searchNameLocal = ( export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => { return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`; }; + +export const zoomImageToBase64 = async ( + face: AssetFaceResponseDto, + photoViewer: HTMLImageElement | null, +): Promise => { + 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; + } +};