mirror of
https://github.com/immich-app/immich.git
synced 2025-12-11 15:21:01 -08:00
Compare commits
10 Commits
fix/server
...
feat/unass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e36b7240c7 | ||
|
|
fec3a04123 | ||
|
|
d919d2c975 | ||
|
|
7ac9c1bb78 | ||
|
|
0f6e665d99 | ||
|
|
7ced61e67d | ||
|
|
ac26d2f45f | ||
|
|
782d02a5e2 | ||
|
|
22c42dd8ff | ||
|
|
7e9dcaacff |
@@ -215,15 +215,18 @@ describe('/asset', () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: user1Assets[0].id,
|
id: user1Assets[0].id,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
people: [
|
people: {
|
||||||
{
|
visiblePeople: [
|
||||||
birthDate: null,
|
{
|
||||||
id: expect.any(String),
|
birthDate: null,
|
||||||
isHidden: false,
|
id: expect.any(String),
|
||||||
name: 'Test Person',
|
isHidden: false,
|
||||||
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
name: 'Test Person',
|
||||||
},
|
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
||||||
],
|
},
|
||||||
|
],
|
||||||
|
numberOfFaces: 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sharedLink = await utils.createSharedLink(user1.accessToken, {
|
const sharedLink = await utils.createSharedLink(user1.accessToken, {
|
||||||
@@ -233,7 +236,7 @@ describe('/asset', () => {
|
|||||||
|
|
||||||
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
|
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
|
||||||
expect(data.status).toBe(200);
|
expect(data.status).toBe(200);
|
||||||
expect(data.body).toMatchObject({ people: [] });
|
expect(data.body).not.toHaveProperty('people');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('partner assets', () => {
|
describe('partner assets', () => {
|
||||||
@@ -511,15 +514,18 @@ describe('/asset', () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
id: user1Assets[0].id,
|
id: user1Assets[0].id,
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
people: [
|
people: {
|
||||||
{
|
visiblePeople: [
|
||||||
birthDate: null,
|
{
|
||||||
id: expect.any(String),
|
birthDate: null,
|
||||||
isHidden: false,
|
id: expect.any(String),
|
||||||
name: 'Test Person',
|
isHidden: false,
|
||||||
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
name: 'Test Person',
|
||||||
},
|
thumbnailPath: '/my/awesome/thumbnail.jpg',
|
||||||
],
|
},
|
||||||
|
],
|
||||||
|
numberOfFaces: 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class AssetService {
|
|||||||
final AssetResponseDto? dto =
|
final AssetResponseDto? dto =
|
||||||
await _apiService.assetApi.getAssetInfo(remoteId);
|
await _apiService.assetApi.getAssetInfo(remoteId);
|
||||||
|
|
||||||
return dto?.people;
|
return dto?.people?.visiblePeople;
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
log.severe(
|
log.severe(
|
||||||
'Error while getting remote asset info: ${error.toString()}',
|
'Error while getting remote asset info: ${error.toString()}',
|
||||||
|
|||||||
3
mobile/openapi/README.md
generated
3
mobile/openapi/README.md
generated
@@ -122,6 +122,7 @@ Class | Method | HTTP request | Description
|
|||||||
*DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates |
|
*DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates |
|
||||||
*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /faces |
|
*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /faces |
|
||||||
*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
|
*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
|
||||||
|
*FaceApi* | [**unassignFace**](doc//FaceApi.md#unassignface) | **DELETE** /faces/{id} |
|
||||||
*FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /reports/fix |
|
*FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /reports/fix |
|
||||||
*FileReportApi* | [**getAuditFiles**](doc//FileReportApi.md#getauditfiles) | **GET** /reports |
|
*FileReportApi* | [**getAuditFiles**](doc//FileReportApi.md#getauditfiles) | **GET** /reports |
|
||||||
*FileReportApi* | [**getFileChecksums**](doc//FileReportApi.md#getfilechecksums) | **POST** /reports/checksum |
|
*FileReportApi* | [**getFileChecksums**](doc//FileReportApi.md#getfilechecksums) | **POST** /reports/checksum |
|
||||||
@@ -160,6 +161,7 @@ Class | Method | HTTP request | Description
|
|||||||
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail |
|
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail |
|
||||||
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /people/{id}/merge |
|
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /people/{id}/merge |
|
||||||
*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /people/{id}/reassign |
|
*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /people/{id}/reassign |
|
||||||
|
*PersonApi* | [**unassignFaces**](doc//PersonApi.md#unassignfaces) | **DELETE** /people |
|
||||||
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /people |
|
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /people |
|
||||||
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /people/{id} |
|
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /people/{id} |
|
||||||
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities |
|
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities |
|
||||||
@@ -329,6 +331,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [PeopleResponseDto](doc//PeopleResponseDto.md)
|
- [PeopleResponseDto](doc//PeopleResponseDto.md)
|
||||||
- [PeopleUpdateDto](doc//PeopleUpdateDto.md)
|
- [PeopleUpdateDto](doc//PeopleUpdateDto.md)
|
||||||
- [PeopleUpdateItem](doc//PeopleUpdateItem.md)
|
- [PeopleUpdateItem](doc//PeopleUpdateItem.md)
|
||||||
|
- [PeopleWithFacesResponseDto](doc//PeopleWithFacesResponseDto.md)
|
||||||
- [PersonCreateDto](doc//PersonCreateDto.md)
|
- [PersonCreateDto](doc//PersonCreateDto.md)
|
||||||
- [PersonResponseDto](doc//PersonResponseDto.md)
|
- [PersonResponseDto](doc//PersonResponseDto.md)
|
||||||
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
||||||
|
|||||||
16
mobile/openapi/doc/PeopleWithFacesResponseDto.md
generated
Normal file
16
mobile/openapi/doc/PeopleWithFacesResponseDto.md
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# openapi.model.PeopleWithFacesResponseDto
|
||||||
|
|
||||||
|
## Load the model package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**numberOfFaces** | **int** | |
|
||||||
|
**visiblePeople** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) | | [default to const []]
|
||||||
|
|
||||||
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
|
||||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@@ -158,6 +158,7 @@ part 'model/path_type.dart';
|
|||||||
part 'model/people_response_dto.dart';
|
part 'model/people_response_dto.dart';
|
||||||
part 'model/people_update_dto.dart';
|
part 'model/people_update_dto.dart';
|
||||||
part 'model/people_update_item.dart';
|
part 'model/people_update_item.dart';
|
||||||
|
part 'model/people_with_faces_response_dto.dart';
|
||||||
part 'model/person_create_dto.dart';
|
part 'model/person_create_dto.dart';
|
||||||
part 'model/person_response_dto.dart';
|
part 'model/person_response_dto.dart';
|
||||||
part 'model/person_statistics_response_dto.dart';
|
part 'model/person_statistics_response_dto.dart';
|
||||||
|
|||||||
48
mobile/openapi/lib/api/face_api.dart
generated
48
mobile/openapi/lib/api/face_api.dart
generated
@@ -119,4 +119,52 @@ class FaceApi {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'DELETE /faces/{id}' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<Response> unassignFaceWithHttpInfo(String id,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/faces/{id}'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'DELETE',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
Future<AssetFaceResponseDto?> unassignFace(String id,) async {
|
||||||
|
final response = await unassignFaceWithHttpInfo(id,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFaceResponseDto',) as AssetFaceResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
mobile/openapi/lib/api/person_api.dart
generated
50
mobile/openapi/lib/api/person_api.dart
generated
@@ -419,6 +419,56 @@ class PersonApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'DELETE /people' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
|
||||||
|
Future<Response> unassignFacesWithHttpInfo(AssetFaceUpdateDto assetFaceUpdateDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/people';
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
'DELETE',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
|
||||||
|
Future<List<BulkIdResponseDto>?> unassignFaces(AssetFaceUpdateDto assetFaceUpdateDto,) async {
|
||||||
|
final response = await unassignFacesWithHttpInfo(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<BulkIdResponseDto>') as List)
|
||||||
|
.cast<BulkIdResponseDto>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'PUT /people' operation and returns the [Response].
|
/// Performs an HTTP 'PUT /people' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
|
|||||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@@ -384,6 +384,8 @@ class ApiClient {
|
|||||||
return PeopleUpdateDto.fromJson(value);
|
return PeopleUpdateDto.fromJson(value);
|
||||||
case 'PeopleUpdateItem':
|
case 'PeopleUpdateItem':
|
||||||
return PeopleUpdateItem.fromJson(value);
|
return PeopleUpdateItem.fromJson(value);
|
||||||
|
case 'PeopleWithFacesResponseDto':
|
||||||
|
return PeopleWithFacesResponseDto.fromJson(value);
|
||||||
case 'PersonCreateDto':
|
case 'PersonCreateDto':
|
||||||
return PersonCreateDto.fromJson(value);
|
return PersonCreateDto.fromJson(value);
|
||||||
case 'PersonResponseDto':
|
case 'PersonResponseDto':
|
||||||
|
|||||||
20
mobile/openapi/lib/model/asset_response_dto.dart
generated
20
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -34,7 +34,7 @@ class AssetResponseDto {
|
|||||||
required this.originalPath,
|
required this.originalPath,
|
||||||
this.owner,
|
this.owner,
|
||||||
required this.ownerId,
|
required this.ownerId,
|
||||||
this.people = const [],
|
this.people,
|
||||||
required this.resized,
|
required this.resized,
|
||||||
this.smartInfo,
|
this.smartInfo,
|
||||||
this.stack = const [],
|
this.stack = const [],
|
||||||
@@ -102,7 +102,13 @@ class AssetResponseDto {
|
|||||||
|
|
||||||
String ownerId;
|
String ownerId;
|
||||||
|
|
||||||
List<PersonWithFacesResponseDto> people;
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
PeopleWithFacesResponseDto? people;
|
||||||
|
|
||||||
bool resized;
|
bool resized;
|
||||||
|
|
||||||
@@ -151,7 +157,7 @@ class AssetResponseDto {
|
|||||||
other.originalPath == originalPath &&
|
other.originalPath == originalPath &&
|
||||||
other.owner == owner &&
|
other.owner == owner &&
|
||||||
other.ownerId == ownerId &&
|
other.ownerId == ownerId &&
|
||||||
_deepEquality.equals(other.people, people) &&
|
other.people == people &&
|
||||||
other.resized == resized &&
|
other.resized == resized &&
|
||||||
other.smartInfo == smartInfo &&
|
other.smartInfo == smartInfo &&
|
||||||
_deepEquality.equals(other.stack, stack) &&
|
_deepEquality.equals(other.stack, stack) &&
|
||||||
@@ -186,7 +192,7 @@ class AssetResponseDto {
|
|||||||
(originalPath.hashCode) +
|
(originalPath.hashCode) +
|
||||||
(owner == null ? 0 : owner!.hashCode) +
|
(owner == null ? 0 : owner!.hashCode) +
|
||||||
(ownerId.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(people.hashCode) +
|
(people == null ? 0 : people!.hashCode) +
|
||||||
(resized.hashCode) +
|
(resized.hashCode) +
|
||||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||||
(stack.hashCode) +
|
(stack.hashCode) +
|
||||||
@@ -243,7 +249,11 @@ class AssetResponseDto {
|
|||||||
// json[r'owner'] = null;
|
// json[r'owner'] = null;
|
||||||
}
|
}
|
||||||
json[r'ownerId'] = this.ownerId;
|
json[r'ownerId'] = this.ownerId;
|
||||||
|
if (this.people != null) {
|
||||||
json[r'people'] = this.people;
|
json[r'people'] = this.people;
|
||||||
|
} else {
|
||||||
|
// json[r'people'] = null;
|
||||||
|
}
|
||||||
json[r'resized'] = this.resized;
|
json[r'resized'] = this.resized;
|
||||||
if (this.smartInfo != null) {
|
if (this.smartInfo != null) {
|
||||||
json[r'smartInfo'] = this.smartInfo;
|
json[r'smartInfo'] = this.smartInfo;
|
||||||
@@ -301,7 +311,7 @@ class AssetResponseDto {
|
|||||||
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
||||||
owner: UserResponseDto.fromJson(json[r'owner']),
|
owner: UserResponseDto.fromJson(json[r'owner']),
|
||||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||||
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
|
people: PeopleWithFacesResponseDto.fromJson(json[r'people']),
|
||||||
resized: mapValueOfType<bool>(json, r'resized')!,
|
resized: mapValueOfType<bool>(json, r'resized')!,
|
||||||
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
|
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
|
||||||
stack: AssetResponseDto.listFromJson(json[r'stack']),
|
stack: AssetResponseDto.listFromJson(json[r'stack']),
|
||||||
|
|||||||
106
mobile/openapi/lib/model/people_with_faces_response_dto.dart
generated
Normal file
106
mobile/openapi/lib/model/people_with_faces_response_dto.dart
generated
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PeopleWithFacesResponseDto {
|
||||||
|
/// Returns a new [PeopleWithFacesResponseDto] instance.
|
||||||
|
PeopleWithFacesResponseDto({
|
||||||
|
required this.numberOfFaces,
|
||||||
|
this.visiblePeople = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
int numberOfFaces;
|
||||||
|
|
||||||
|
List<PersonWithFacesResponseDto> visiblePeople;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PeopleWithFacesResponseDto &&
|
||||||
|
other.numberOfFaces == numberOfFaces &&
|
||||||
|
_deepEquality.equals(other.visiblePeople, visiblePeople);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(numberOfFaces.hashCode) +
|
||||||
|
(visiblePeople.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PeopleWithFacesResponseDto[numberOfFaces=$numberOfFaces, visiblePeople=$visiblePeople]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'numberOfFaces'] = this.numberOfFaces;
|
||||||
|
json[r'visiblePeople'] = this.visiblePeople;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PeopleWithFacesResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PeopleWithFacesResponseDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return PeopleWithFacesResponseDto(
|
||||||
|
numberOfFaces: mapValueOfType<int>(json, r'numberOfFaces')!,
|
||||||
|
visiblePeople: PersonWithFacesResponseDto.listFromJson(json[r'visiblePeople']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PeopleWithFacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PeopleWithFacesResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PeopleWithFacesResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PeopleWithFacesResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PeopleWithFacesResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PeopleWithFacesResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PeopleWithFacesResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PeopleWithFacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PeopleWithFacesResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PeopleWithFacesResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'numberOfFaces',
|
||||||
|
'visiblePeople',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
32
mobile/openapi/test/people_with_faces_response_dto_test.dart
generated
Normal file
32
mobile/openapi/test/people_with_faces_response_dto_test.dart
generated
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// tests for PeopleWithFacesResponseDto
|
||||||
|
void main() {
|
||||||
|
// final instance = PeopleWithFacesResponseDto();
|
||||||
|
|
||||||
|
group('test PeopleWithFacesResponseDto', () {
|
||||||
|
// int numberOfFaces
|
||||||
|
test('to test the property `numberOfFaces`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// List<PersonWithFacesResponseDto> visiblePeople (default value: const [])
|
||||||
|
test('to test the property `visiblePeople`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2531,6 +2531,46 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/faces/{id}": {
|
"/faces/{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": {
|
"put": {
|
||||||
"operationId": "reassignFacesById",
|
"operationId": "reassignFacesById",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -3671,6 +3711,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/people": {
|
"/people": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "unassignFaces",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetFaceUpdateDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/BulkIdResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Person"
|
||||||
|
]
|
||||||
|
},
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAllPeople",
|
"operationId": "getAllPeople",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@@ -7499,10 +7571,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"people": {
|
"people": {
|
||||||
"items": {
|
"$ref": "#/components/schemas/PeopleWithFacesResponseDto"
|
||||||
"$ref": "#/components/schemas/PersonWithFacesResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
},
|
||||||
"resized": {
|
"resized": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -8995,6 +9064,24 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PeopleWithFacesResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"numberOfFaces": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"visiblePeople": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PersonWithFacesResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"numberOfFaces",
|
||||||
|
"visiblePeople"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PersonCreateDto": {
|
"PersonCreateDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"birthDate": {
|
"birthDate": {
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export type PersonWithFacesResponseDto = {
|
|||||||
name: string;
|
name: string;
|
||||||
thumbnailPath: string;
|
thumbnailPath: string;
|
||||||
};
|
};
|
||||||
|
export type PeopleWithFacesResponseDto = {
|
||||||
|
numberOfFaces: number;
|
||||||
|
visiblePeople: PersonWithFacesResponseDto[];
|
||||||
|
};
|
||||||
export type SmartInfoResponseDto = {
|
export type SmartInfoResponseDto = {
|
||||||
objects?: string[] | null;
|
objects?: string[] | null;
|
||||||
tags?: string[] | null;
|
tags?: string[] | null;
|
||||||
@@ -157,7 +161,7 @@ export type AssetResponseDto = {
|
|||||||
originalPath: string;
|
originalPath: string;
|
||||||
owner?: UserResponseDto;
|
owner?: UserResponseDto;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
people?: PersonWithFacesResponseDto[];
|
people?: PeopleWithFacesResponseDto;
|
||||||
resized: boolean;
|
resized: boolean;
|
||||||
smartInfo?: SmartInfoResponseDto;
|
smartInfo?: SmartInfoResponseDto;
|
||||||
stack?: AssetResponseDto[];
|
stack?: AssetResponseDto[];
|
||||||
@@ -549,6 +553,13 @@ export type PartnerResponseDto = {
|
|||||||
export type UpdatePartnerDto = {
|
export type UpdatePartnerDto = {
|
||||||
inTimeline: boolean;
|
inTimeline: boolean;
|
||||||
};
|
};
|
||||||
|
export type AssetFaceUpdateItem = {
|
||||||
|
assetId: string;
|
||||||
|
personId: string;
|
||||||
|
};
|
||||||
|
export type AssetFaceUpdateDto = {
|
||||||
|
data: AssetFaceUpdateItem[];
|
||||||
|
};
|
||||||
export type PeopleResponseDto = {
|
export type PeopleResponseDto = {
|
||||||
hidden: number;
|
hidden: number;
|
||||||
people: PersonResponseDto[];
|
people: PersonResponseDto[];
|
||||||
@@ -593,13 +604,6 @@ export type PersonUpdateDto = {
|
|||||||
export type MergePersonDto = {
|
export type MergePersonDto = {
|
||||||
ids: string[];
|
ids: string[];
|
||||||
};
|
};
|
||||||
export type AssetFaceUpdateItem = {
|
|
||||||
assetId: string;
|
|
||||||
personId: string;
|
|
||||||
};
|
|
||||||
export type AssetFaceUpdateDto = {
|
|
||||||
data: AssetFaceUpdateItem[];
|
|
||||||
};
|
|
||||||
export type PersonStatisticsResponseDto = {
|
export type PersonStatisticsResponseDto = {
|
||||||
assets: number;
|
assets: number;
|
||||||
};
|
};
|
||||||
@@ -1767,6 +1771,17 @@ export function getFaces({ id }: {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export function unassignFace({ id }: {
|
||||||
|
id: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AssetFaceResponseDto;
|
||||||
|
}>(`/faces/${encodeURIComponent(id)}`, {
|
||||||
|
...opts,
|
||||||
|
method: "DELETE"
|
||||||
|
}));
|
||||||
|
}
|
||||||
export function reassignFacesById({ id, faceDto }: {
|
export function reassignFacesById({ id, faceDto }: {
|
||||||
id: string;
|
id: string;
|
||||||
faceDto: FaceDto;
|
faceDto: FaceDto;
|
||||||
@@ -2064,6 +2079,18 @@ export function updatePartner({ id, updatePartnerDto }: {
|
|||||||
body: updatePartnerDto
|
body: updatePartnerDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
export function unassignFaces({ assetFaceUpdateDto }: {
|
||||||
|
assetFaceUpdateDto: AssetFaceUpdateDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: BulkIdResponseDto[];
|
||||||
|
}>("/people", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "DELETE",
|
||||||
|
body: assetFaceUpdateDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
export function getAllPeople({ withHidden }: {
|
export function getAllPeople({ withHidden }: {
|
||||||
withHidden?: boolean;
|
withHidden?: boolean;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
|
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
|
||||||
@@ -26,4 +26,10 @@ export class FaceController {
|
|||||||
): Promise<PersonResponseDto> {
|
): Promise<PersonResponseDto> {
|
||||||
return this.service.reassignFacesById(auth, id, dto);
|
return this.service.reassignFacesById(auth, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@Authenticated()
|
||||||
|
unassignFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetFaceResponseDto> {
|
||||||
|
return this.service.unassignFace(auth, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Inject, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Inject, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { NextFunction, Response } from 'express';
|
import { NextFunction, Response } from 'express';
|
||||||
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
@@ -87,6 +87,11 @@ export class PersonController {
|
|||||||
return this.service.getAssets(auth, id);
|
return this.service.getAssets(auth, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
unassignFaces(@Auth() auth: AuthDto, @Body() dto: AssetFaceUpdateDto): Promise<BulkIdResponseDto[]> {
|
||||||
|
return this.service.unassignFaces(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id/reassign')
|
@Put(':id/reassign')
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
reassignFaces(
|
reassignFaces(
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { PropertyLifecycle } from 'src/decorators';
|
import { PropertyLifecycle } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
||||||
import { PersonWithFacesResponseDto, mapFacesWithoutPerson, mapPerson } from 'src/dtos/person.dto';
|
import {
|
||||||
|
PeopleWithFacesResponseDto,
|
||||||
|
PersonWithFacesResponseDto,
|
||||||
|
mapFacesWithoutPerson,
|
||||||
|
mapPerson,
|
||||||
|
} from 'src/dtos/person.dto';
|
||||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
@@ -40,7 +45,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||||||
exifInfo?: ExifResponseDto;
|
exifInfo?: ExifResponseDto;
|
||||||
smartInfo?: SmartInfoResponseDto;
|
smartInfo?: SmartInfoResponseDto;
|
||||||
tags?: TagResponseDto[];
|
tags?: TagResponseDto[];
|
||||||
people?: PersonWithFacesResponseDto[];
|
people?: PeopleWithFacesResponseDto;
|
||||||
/**base64 encoded sha1 hash */
|
/**base64 encoded sha1 hash */
|
||||||
checksum!: string;
|
checksum!: string;
|
||||||
stackParentId?: string | null;
|
stackParentId?: string | null;
|
||||||
@@ -56,7 +61,7 @@ export type AssetMapOptions = {
|
|||||||
auth?: AuthDto;
|
auth?: AuthDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
|
const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto => {
|
||||||
const result: PersonWithFacesResponseDto[] = [];
|
const result: PersonWithFacesResponseDto[] = [];
|
||||||
if (faces) {
|
if (faces) {
|
||||||
for (const face of faces) {
|
for (const face of faces) {
|
||||||
@@ -71,7 +76,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[]
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return { visiblePeople: result, numberOfFaces: faces.length };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
|
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
|
||||||
@@ -115,7 +120,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
|
|||||||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||||
livePhotoVideoId: entity.livePhotoVideoId,
|
livePhotoVideoId: entity.livePhotoVideoId,
|
||||||
tags: entity.tags?.map(mapTag),
|
tags: entity.tags?.map(mapTag),
|
||||||
people: peopleWithFaces(entity.faces),
|
people: entity.faces ? peopleWithFaces(entity.faces) : undefined,
|
||||||
checksum: entity.checksum.toString('base64'),
|
checksum: entity.checksum.toString('base64'),
|
||||||
stackParentId: withStack ? entity.stack?.primaryAssetId : undefined,
|
stackParentId: withStack ? entity.stack?.primaryAssetId : undefined,
|
||||||
stack: withStack
|
stack: withStack
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export class PersonWithFacesResponseDto extends PersonResponseDto {
|
|||||||
faces!: AssetFaceWithoutPersonResponseDto[];
|
faces!: AssetFaceWithoutPersonResponseDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PeopleWithFacesResponseDto {
|
||||||
|
visiblePeople!: PersonWithFacesResponseDto[];
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
numberOfFaces!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class AssetFaceWithoutPersonResponseDto {
|
export class AssetFaceWithoutPersonResponseDto {
|
||||||
@ValidateUUID()
|
@ValidateUUID()
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export class AssetFaceEntity {
|
|||||||
@Column({ default: 0, type: 'int' })
|
@Column({ default: 0, type: 'int' })
|
||||||
boundingBoxY2!: number;
|
boundingBoxY2!: number;
|
||||||
|
|
||||||
|
@Column({ default: false })
|
||||||
|
isEdited!: boolean;
|
||||||
|
|
||||||
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||||
asset!: AssetEntity;
|
asset!: AssetEntity;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface AssetFaceId {
|
|||||||
export interface UpdateFacesData {
|
export interface UpdateFacesData {
|
||||||
oldPersonId?: string;
|
oldPersonId?: string;
|
||||||
faceIds?: string[];
|
faceIds?: string[];
|
||||||
newPersonId: string;
|
newPersonId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonStatistics {
|
export interface PersonStatistics {
|
||||||
@@ -60,7 +60,7 @@ export interface IPersonRepository {
|
|||||||
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
||||||
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
|
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
|
||||||
getStatistics(personId: string): Promise<PersonStatistics>;
|
getStatistics(personId: string): Promise<PersonStatistics>;
|
||||||
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
reassignFace(assetFaceId: string, newPersonId: string | null): Promise<number>;
|
||||||
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
||||||
reassignFaces(data: UpdateFacesData): Promise<number>;
|
reassignFaces(data: UpdateFacesData): Promise<number>;
|
||||||
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||||
|
|||||||
13
server/src/migrations/1715357609038-AddEditedAssetFace.ts
Normal file
13
server/src/migrations/1715357609038-AddEditedAssetFace.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddEditedAssetFace1715357609038 implements MigrationInterface {
|
||||||
|
name = 'AddEditedAssetFace1715357609038';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "isEdited" boolean NOT NULL DEFAULT false`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "isEdited"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,7 @@ SELECT
|
|||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
||||||
|
"AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ SELECT
|
|||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||||
@@ -103,6 +104,7 @@ FROM
|
|||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||||
@@ -138,6 +140,7 @@ FROM
|
|||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
|
||||||
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
|
||||||
@@ -194,9 +197,10 @@ LIMIT
|
|||||||
-- PersonRepository.reassignFace
|
-- PersonRepository.reassignFace
|
||||||
UPDATE "asset_faces"
|
UPDATE "asset_faces"
|
||||||
SET
|
SET
|
||||||
"personId" = $1
|
"personId" = $1,
|
||||||
|
"isEdited" = $2
|
||||||
WHERE
|
WHERE
|
||||||
"id" = $2
|
"id" = $3
|
||||||
|
|
||||||
-- PersonRepository.getByName
|
-- PersonRepository.getByName
|
||||||
SELECT
|
SELECT
|
||||||
@@ -283,6 +287,7 @@ FROM
|
|||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
|
||||||
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
|
||||||
|
"AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
|
||||||
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
|
||||||
@@ -375,6 +380,7 @@ SELECT
|
|||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
|
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
|
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
|
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
|
||||||
@@ -427,7 +433,8 @@ SELECT
|
|||||||
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
|
||||||
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
|
||||||
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
|
||||||
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2"
|
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
|
||||||
|
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "AssetFaceEntity"
|
"asset_faces" "AssetFaceEntity"
|
||||||
WHERE
|
WHERE
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ WITH
|
|||||||
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
||||||
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
||||||
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
||||||
|
"faces"."isEdited" AS "isEdited",
|
||||||
"faces"."embedding" <= > $1 AS "distance"
|
"faces"."embedding" <= > $1 AS "distance"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "faces"
|
"asset_faces" "faces"
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export class PersonRepository implements IPersonRepository {
|
|||||||
const result = await this.assetFaceRepository
|
const result = await this.assetFaceRepository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update()
|
.update()
|
||||||
.set({ personId: newPersonId })
|
.set({ personId: newPersonId, isEdited: true })
|
||||||
.where({ id: assetFaceId })
|
.where({ id: assetFaceId })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.ownerId !== auth.user.id || auth.sharedLink) {
|
if (data.ownerId !== auth.user.id || auth.sharedLink) {
|
||||||
data.people = [];
|
delete data.people;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -438,6 +438,60 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('unassignFace', () => {
|
||||||
|
it('should unassign a face', async () => {
|
||||||
|
personMock.getFaceById.mockResolvedValueOnce(faceStub.face1);
|
||||||
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||||
|
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||||
|
personMock.reassignFace.mockResolvedValue(1);
|
||||||
|
personMock.getRandomFace.mockResolvedValue(null);
|
||||||
|
personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace);
|
||||||
|
|
||||||
|
await expect(sut.unassignFace(authStub.admin, faceStub.face1.id)).resolves.toStrictEqual(
|
||||||
|
mapFaces(faceStub.unassignedFace, authStub.admin),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not unassign a face if user has no create access', async () => {
|
||||||
|
personMock.getFaceById.mockResolvedValueOnce(faceStub.face1);
|
||||||
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||||
|
personMock.reassignFace.mockResolvedValue(1);
|
||||||
|
personMock.getRandomFace.mockResolvedValue(null);
|
||||||
|
personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace);
|
||||||
|
|
||||||
|
await expect(sut.unassignFace(authStub.admin, faceStub.face1.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unassignFaces', () => {
|
||||||
|
it('should unassign a face', async () => {
|
||||||
|
personMock.getFacesByIds.mockResolvedValueOnce([faceStub.face1]);
|
||||||
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||||
|
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||||
|
personMock.reassignFace.mockResolvedValue(1);
|
||||||
|
personMock.getRandomFace.mockResolvedValue(null);
|
||||||
|
personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.unassignFaces(authStub.admin, { data: [{ assetId: faceStub.face1.id, personId: 'person-1' }] }),
|
||||||
|
).resolves.toStrictEqual([{ id: 'assetFaceId1', success: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not unassign a face if the user has no create access', async () => {
|
||||||
|
personMock.getFacesByIds.mockResolvedValueOnce([faceStub.face1]);
|
||||||
|
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||||
|
personMock.reassignFace.mockResolvedValue(1);
|
||||||
|
personMock.getRandomFace.mockResolvedValue(null);
|
||||||
|
personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.unassignFaces(authStub.admin, { data: [{ assetId: faceStub.face1.id, personId: 'person-1' }] }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('handlePersonCleanup', () => {
|
describe('handlePersonCleanup', () => {
|
||||||
it('should delete people without faces', async () => {
|
it('should delete people without faces', async () => {
|
||||||
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
||||||
@@ -551,7 +605,10 @@ describe(PersonService.name, () => {
|
|||||||
|
|
||||||
await sut.handleQueueRecognizeFaces({});
|
await sut.handleQueueRecognizeFaces({});
|
||||||
|
|
||||||
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } });
|
expect(personMock.getAllFaces).toHaveBeenCalledWith(
|
||||||
|
{ skip: 0, take: 1000 },
|
||||||
|
{ where: { personId: IsNull(), isEdited: false } },
|
||||||
|
);
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACIAL_RECOGNITION,
|
name: JobName.FACIAL_RECOGNITION,
|
||||||
|
|||||||
@@ -102,6 +102,22 @@ export class PersonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unassignFace(auth: AuthDto, id: string): Promise<AssetFaceResponseDto> {
|
||||||
|
let face = await this.repository.getFaceById(id);
|
||||||
|
await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id);
|
||||||
|
|
||||||
|
if (face.personId) {
|
||||||
|
await this.access.requirePermission(auth, 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, auth);
|
||||||
|
}
|
||||||
|
|
||||||
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
||||||
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
|
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
|
||||||
const person = await this.findOrFail(personId);
|
const person = await this.findOrFail(personId);
|
||||||
@@ -131,6 +147,34 @@ export class PersonService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async unassignFaces(auth: AuthDto, dto: AssetFaceUpdateDto): Promise<BulkIdResponseDto[]> {
|
||||||
|
const changeFeaturePhoto: string[] = [];
|
||||||
|
const results: BulkIdResponseDto[] = [];
|
||||||
|
|
||||||
|
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(auth, Permission.PERSON_CREATE, face.id);
|
||||||
|
if (face.personId) {
|
||||||
|
await this.access.requirePermission(auth, Permission.PERSON_WRITE, face.personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.reassignFace(face.id, null);
|
||||||
|
if (face.person && face.person.faceAssetId === face.id) {
|
||||||
|
changeFeaturePhoto.push(face.person.id);
|
||||||
|
}
|
||||||
|
results.push({ id: face.id, success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changeFeaturePhoto.length > 0) {
|
||||||
|
// Remove duplicates
|
||||||
|
await this.createNewFeaturePhoto([...changeFeaturePhoto]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
||||||
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
|
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
|
||||||
|
|
||||||
@@ -387,7 +431,7 @@ export class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||||
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }),
|
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull(), isEdited: false } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const page of facePagination) {
|
for await (const page of facePagination) {
|
||||||
|
|||||||
24
server/test/fixtures/face.stub.ts
vendored
24
server/test/fixtures/face.stub.ts
vendored
@@ -18,6 +18,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId2',
|
id: 'assetFaceId2',
|
||||||
@@ -32,6 +33,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId3',
|
id: 'assetFaceId3',
|
||||||
@@ -46,6 +48,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId4',
|
id: 'assetFaceId4',
|
||||||
@@ -60,6 +63,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId5',
|
id: 'assetFaceId5',
|
||||||
@@ -74,6 +78,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 505,
|
boundingBoxY2: 505,
|
||||||
imageHeight: 1000,
|
imageHeight: 1000,
|
||||||
imageWidth: 1000,
|
imageWidth: 1000,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId6',
|
id: 'assetFaceId6',
|
||||||
@@ -88,6 +93,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 200,
|
boundingBoxY2: 200,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
||||||
id: 'assetFaceId7',
|
id: 'assetFaceId7',
|
||||||
@@ -102,6 +108,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 495,
|
boundingBoxY2: 495,
|
||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 500,
|
imageWidth: 500,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
noPerson1: Object.freeze<AssetFaceEntity>({
|
noPerson1: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId8',
|
id: 'assetFaceId8',
|
||||||
@@ -116,6 +123,7 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
noPerson2: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
@@ -130,5 +138,21 @@ export const faceStub = {
|
|||||||
boundingBoxY2: 1,
|
boundingBoxY2: 1,
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
|
}),
|
||||||
|
unassignedFace: Object.freeze<AssetFaceEntity>({
|
||||||
|
id: 'assetFaceId',
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
asset: assetStub.image,
|
||||||
|
personId: null,
|
||||||
|
person: null,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
boundingBoxX1: 0,
|
||||||
|
boundingBoxY1: 0,
|
||||||
|
boundingBoxX2: 1,
|
||||||
|
boundingBoxY2: 1,
|
||||||
|
imageHeight: 1024,
|
||||||
|
imageWidth: 1024,
|
||||||
|
isEdited: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
2
server/test/fixtures/shared-link.stub.ts
vendored
2
server/test/fixtures/shared-link.stub.ts
vendored
@@ -71,7 +71,7 @@ const assetResponse: AssetResponseDto = {
|
|||||||
exifInfo: assetInfo,
|
exifInfo: assetInfo,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
people: [],
|
people: undefined,
|
||||||
checksum: 'ZmlsZSBoYXNo',
|
checksum: 'ZmlsZSBoYXNo',
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
libraryId: 'library-id',
|
libraryId: 'library-id',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
// TODO: check if reloading asset data is necessary
|
// TODO: check if reloading asset data is necessary
|
||||||
if (newAsset.id && !isSharedLink()) {
|
if (newAsset.id && !isSharedLink()) {
|
||||||
const data = await getAssetInfo({ id: asset.id });
|
const data = await getAssetInfo({ id: asset.id });
|
||||||
people = data?.people || [];
|
people = data?.people || undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: people = asset.people || [];
|
$: people = asset?.people || undefined;
|
||||||
$: showingHiddenPeople = false;
|
$: showingHiddenPeople = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
|
|
||||||
const handleRefreshPeople = async () => {
|
const handleRefreshPeople = async () => {
|
||||||
await getAssetInfo({ id: asset.id }).then((data) => {
|
await getAssetInfo({ id: asset.id }).then((data) => {
|
||||||
people = data?.people || [];
|
people = data?.people || undefined;
|
||||||
});
|
});
|
||||||
showEditFaces = false;
|
showEditFaces = false;
|
||||||
};
|
};
|
||||||
@@ -158,12 +158,12 @@
|
|||||||
|
|
||||||
<DetailPanelDescription {asset} {isOwner} />
|
<DetailPanelDescription {asset} {isOwner} />
|
||||||
|
|
||||||
{#if !isSharedLink() && people.length > 0}
|
{#if !isSharedLink() && people?.numberOfFaces && people?.numberOfFaces > 0}
|
||||||
<section class="px-4 py-4 text-sm">
|
<section class="px-4 py-4 text-sm">
|
||||||
<div class="flex h-10 w-full items-center justify-between">
|
<div class="flex h-10 w-full items-center justify-between">
|
||||||
<h2>PEOPLE</h2>
|
<h2>PEOPLE</h2>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{#if people.some((person) => person.isHidden)}
|
{#if people.visiblePeople.some((person) => person.isHidden)}
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
title="Show hidden people"
|
title="Show hidden people"
|
||||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||||
@@ -184,16 +184,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
{#each people as person, index (person.id)}
|
{#each people.visiblePeople as person (person.id)}
|
||||||
{#if showingHiddenPeople || !person.isHidden}
|
{#if showingHiddenPeople || !person.isHidden}
|
||||||
<a
|
<a
|
||||||
class="w-[90px]"
|
class="w-[90px]"
|
||||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
|
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
|
||||||
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
|
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
|
||||||
: AppRoute.PHOTOS}"
|
: AppRoute.PHOTOS}"
|
||||||
on:focus={() => ($boundingBoxesArray = people[index].faces)}
|
on:focus={() => ($boundingBoxesArray = person.faces)}
|
||||||
on:blur={() => ($boundingBoxesArray = [])}
|
on:blur={() => ($boundingBoxesArray = [])}
|
||||||
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
|
on:mouseover={() => ($boundingBoxesArray = person.faces)}
|
||||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -498,9 +498,9 @@
|
|||||||
<PersonSidePanel
|
<PersonSidePanel
|
||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
assetType={asset.type}
|
assetType={asset.type}
|
||||||
on:close={() => {
|
onClose={() => {
|
||||||
showEditFaces = false;
|
showEditFaces = false;
|
||||||
}}
|
}}
|
||||||
on:refresh={handleRefreshPeople}
|
onRefresh={handleRefreshPeople}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
|
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'blue' | 'red' | 'green';
|
||||||
|
|
||||||
export let type: 'button' | 'submit' | 'reset' = 'button';
|
export let type: 'button' | 'submit' | 'reset' = 'button';
|
||||||
export let icon: string;
|
export let icon: string;
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
* viewBox attribute for the SVG icon.
|
* viewBox attribute for the SVG icon.
|
||||||
*/
|
*/
|
||||||
export let viewBox: string | undefined = undefined;
|
export let viewBox: string | undefined = undefined;
|
||||||
|
export let disableHover = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override the default styling of the button for specific use cases, such as the icon color.
|
* Override the default styling of the button for specific use cases, such as the icon color.
|
||||||
@@ -29,6 +30,9 @@
|
|||||||
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
|
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
|
||||||
primary:
|
primary:
|
||||||
'bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 hover:dark:bg-immich-dark-primary/80 text-white dark:text-immich-dark-gray',
|
'bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 hover:dark:bg-immich-dark-primary/80 text-white dark:text-immich-dark-gray',
|
||||||
|
blue: 'bg-blue-700',
|
||||||
|
red: 'bg-red-700',
|
||||||
|
green: 'bg-green-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
$: colorClass = colorClasses[color];
|
$: colorClass = colorClasses[color];
|
||||||
@@ -40,7 +44,9 @@
|
|||||||
{type}
|
{type}
|
||||||
style:width={buttonSize ? buttonSize + 'px' : ''}
|
style:width={buttonSize ? buttonSize + 'px' : ''}
|
||||||
style:height={buttonSize ? buttonSize + 'px' : ''}
|
style:height={buttonSize ? buttonSize + 'px' : ''}
|
||||||
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
|
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all {disableHover
|
||||||
|
? ''
|
||||||
|
: 'hover:dark:text-immich-dark-gray'} {className} {mobileClass}"
|
||||||
on:click
|
on:click
|
||||||
>
|
>
|
||||||
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
|
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
import { photoViewer } from '$lib/stores/assets.store';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
|
||||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { linear } from 'svelte/easing';
|
import { linear } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
|
||||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
|
||||||
export let peopleWithFaces: AssetFaceResponseDto[];
|
export let editedFace: AssetFaceResponseDto;
|
||||||
export let allPeople: PersonResponseDto[];
|
export let allPeople: PersonResponseDto[];
|
||||||
export let editedPerson: PersonResponseDto;
|
|
||||||
export let assetType: AssetTypeEnum;
|
export let assetType: AssetTypeEnum;
|
||||||
export let assetId: string;
|
export let assetId: string;
|
||||||
|
export let editedPerson: PersonResponseDto | undefined = undefined;
|
||||||
|
export let onClose = () => {};
|
||||||
|
export let onCreatePerson: (featurePhoto: string | null) => void;
|
||||||
|
export let onReassign: (person: PersonResponseDto) => void;
|
||||||
|
|
||||||
// loading spinners
|
// loading spinners
|
||||||
let isShowLoadingNewPerson = false;
|
let isShowLoadingNewPerson = false;
|
||||||
@@ -30,85 +32,20 @@
|
|||||||
|
|
||||||
$: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden);
|
$: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden);
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
close: void;
|
|
||||||
createPerson: string | null;
|
|
||||||
reassign: PersonResponseDto;
|
|
||||||
}>();
|
|
||||||
const handleBackButton = () => {
|
const handleBackButton = () => {
|
||||||
dispatch('close');
|
onClose();
|
||||||
};
|
|
||||||
const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise<string | null> => {
|
|
||||||
let image: HTMLImageElement | null = null;
|
|
||||||
if (assetType === AssetTypeEnum.Image) {
|
|
||||||
image = $photoViewer;
|
|
||||||
} else if (assetType === AssetTypeEnum.Video) {
|
|
||||||
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
|
||||||
const img: HTMLImageElement = new Image();
|
|
||||||
img.src = data;
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
img.addEventListener('load', () => resolve());
|
|
||||||
img.addEventListener('error', () => resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
image = img;
|
|
||||||
}
|
|
||||||
if (image === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
boundingBoxX1: x1,
|
|
||||||
boundingBoxX2: x2,
|
|
||||||
boundingBoxY1: y1,
|
|
||||||
boundingBoxY2: y2,
|
|
||||||
imageWidth,
|
|
||||||
imageHeight,
|
|
||||||
} = face;
|
|
||||||
|
|
||||||
const coordinates = {
|
|
||||||
x1: (image.naturalWidth / imageWidth) * x1,
|
|
||||||
x2: (image.naturalWidth / imageWidth) * x2,
|
|
||||||
y1: (image.naturalHeight / imageHeight) * y1,
|
|
||||||
y2: (image.naturalHeight / imageHeight) * y2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const faceWidth = coordinates.x2 - coordinates.x1;
|
|
||||||
const faceHeight = coordinates.y2 - coordinates.y1;
|
|
||||||
|
|
||||||
const faceImage = new Image();
|
|
||||||
faceImage.src = image.src;
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
faceImage.addEventListener('load', resolve);
|
|
||||||
faceImage.addEventListener('error', () => resolve(null));
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = faceWidth;
|
|
||||||
canvas.height = faceHeight;
|
|
||||||
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (context) {
|
|
||||||
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
|
||||||
|
|
||||||
return canvas.toDataURL();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreatePerson = async () => {
|
const handleCreatePerson = async () => {
|
||||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
|
||||||
|
|
||||||
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetType, assetId);
|
||||||
|
|
||||||
dispatch('createPerson', newFeaturePhoto);
|
onCreatePerson(newFeaturePhoto);
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
isShowLoadingNewPerson = false;
|
isShowLoadingNewPerson = false;
|
||||||
dispatch('createPerson', newFeaturePhoto);
|
onCreatePerson(newFeaturePhoto);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -157,33 +94,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-4 text-sm">
|
<div class="px-4 py-4 text-sm">
|
||||||
<h2 class="mb-8 mt-4 uppercase">All people</h2>
|
{#if showPeople.length > 0}
|
||||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
<h2 class="mb-8 mt-4 uppercase">All people</h2>
|
||||||
{#each showPeople as person (person.id)}
|
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||||
{#if person.id !== editedPerson.id}
|
{#each showPeople as person (person.id)}
|
||||||
<div class="w-fit">
|
{#if person.id !== editedPerson?.id}
|
||||||
<button type="button" class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
<div class="w-fit">
|
||||||
<div class="relative">
|
<button type="button" class="w-[90px]" on:click={() => onReassign(person)}>
|
||||||
<ImageThumbnail
|
<div class="relative">
|
||||||
curve
|
<ImageThumbnail
|
||||||
shadow
|
curve
|
||||||
url={getPeopleThumbnailUrl(person.id)}
|
shadow
|
||||||
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
url={getPeopleThumbnailUrl(person.id)}
|
||||||
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||||
widthStyle="90px"
|
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||||
heightStyle="90px"
|
widthStyle="90px"
|
||||||
thumbhash={null}
|
heightStyle="90px"
|
||||||
hidden={person.isHidden}
|
thumbhash={null}
|
||||||
/>
|
hidden={person.isHidden}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
|
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
|
||||||
{person.name}
|
{person.name}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="grid place-items-center">
|
||||||
|
<Icon path={mdiAccountOff} size="3.5em" />
|
||||||
|
<p class="mt-5 font-medium">No faces found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,38 +5,51 @@
|
|||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
createPerson,
|
createPerson,
|
||||||
getAllPeople,
|
getAllPeople,
|
||||||
getFaces,
|
getFaces,
|
||||||
reassignFacesById,
|
reassignFacesById,
|
||||||
|
unassignFace,
|
||||||
type AssetFaceResponseDto,
|
type AssetFaceResponseDto,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js';
|
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiFaceMan, mdiMinus, mdiRestart } from '@mdi/js';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { linear } from 'svelte/easing';
|
import { linear } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
|
||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
|
||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
|
import type { FaceWithGeneratedThumbnail } from '$lib/utils/people-utils';
|
||||||
|
import {
|
||||||
|
NotificationType,
|
||||||
|
notificationController,
|
||||||
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import AssignFaceSidePanel from '$lib/components/faces-page/assign-face-side-panel.svelte';
|
||||||
|
import UnassignedFacesSidePanel from '$lib/components/faces-page/unassigned-faces-side-panel.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
|
||||||
export let assetId: string;
|
export let assetId: string;
|
||||||
export let assetType: AssetTypeEnum;
|
export let assetType: AssetTypeEnum;
|
||||||
|
export let onClose = () => {};
|
||||||
|
export let onRefresh = () => {};
|
||||||
|
|
||||||
// keep track of the changes
|
// keep track of the changes
|
||||||
let peopleToCreate: string[] = [];
|
let peopleToCreate: string[] = [];
|
||||||
let assetFaceGenerated: string[] = [];
|
let assetFaceGenerated: string[] = [];
|
||||||
|
|
||||||
// faces
|
// faces
|
||||||
|
let allFaces: AssetFaceResponseDto[] = [];
|
||||||
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
||||||
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
||||||
let selectedPersonToCreate: Record<string, string> = {};
|
let selectedPersonToCreate: Record<string, string> = {};
|
||||||
|
let selectedPersonToAdd: Record<string, FaceWithGeneratedThumbnail> = {};
|
||||||
|
let selectedFaceToRemove: Record<string, AssetFaceResponseDto> = {};
|
||||||
let editedPerson: PersonResponseDto;
|
let editedPerson: PersonResponseDto;
|
||||||
let editedFace: AssetFaceResponseDto;
|
let editedFace: AssetFaceResponseDto;
|
||||||
|
let unassignedFaces: FaceWithGeneratedThumbnail[] = [];
|
||||||
|
|
||||||
// loading spinners
|
// loading spinners
|
||||||
let isShowLoadingDone = false;
|
let isShowLoadingDone = false;
|
||||||
@@ -44,25 +57,39 @@
|
|||||||
|
|
||||||
// search people
|
// search people
|
||||||
let showSelectedFaces = false;
|
let showSelectedFaces = false;
|
||||||
|
let showUnassignedFaces = false;
|
||||||
let allPeople: PersonResponseDto[] = [];
|
let allPeople: PersonResponseDto[] = [];
|
||||||
|
|
||||||
// timers
|
// timers
|
||||||
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
||||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
$: mapFacesToBeCreated = Object.entries(selectedPersonToAdd)
|
||||||
|
.filter(([_, value]) => value.person === null)
|
||||||
|
.map(([key, _]) => key);
|
||||||
|
|
||||||
const thumbnailWidth = '90px';
|
const thumbnailWidth = '90px';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const generatePeopleWithoutFaces = async () => {
|
||||||
close: void;
|
const peopleWithGeneratedImage = await Promise.all(
|
||||||
refresh: void;
|
allFaces.map(async (personWithFace) => {
|
||||||
}>();
|
if (personWithFace.person === null) {
|
||||||
|
const image = await zoomImageToBase64(personWithFace, assetType, assetId);
|
||||||
|
return { ...personWithFace, customThumbnail: image };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unassignedFaces = peopleWithGeneratedImage.filter((item): item is FaceWithGeneratedThumbnail => item !== undefined);
|
||||||
|
};
|
||||||
|
|
||||||
async function loadPeople() {
|
async function loadPeople() {
|
||||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||||
try {
|
try {
|
||||||
const { people } = await getAllPeople({ withHidden: true });
|
const { people } = await getAllPeople({ withHidden: true });
|
||||||
allPeople = people;
|
allPeople = people;
|
||||||
peopleWithFaces = await getFaces({ id: assetId });
|
allFaces = await getFaces({ id: assetId });
|
||||||
|
peopleWithFaces = allFaces.filter((face) => face.person);
|
||||||
|
await generatePeopleWithoutFaces();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, "Can't get faces");
|
handleError(error, "Can't get faces");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,17 +98,26 @@
|
|||||||
isShowLoadingPeople = false;
|
isShowLoadingPeople = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* we wait for the server to create the feature photo for:
|
||||||
|
* - people which has been reassigned to a new person
|
||||||
|
* - faces removed assigned to a new person
|
||||||
|
*
|
||||||
|
* if after 15 seconds the server has not generated the feature photos,
|
||||||
|
* we go back to the detail-panel
|
||||||
|
*/
|
||||||
const onPersonThumbnail = (personId: string) => {
|
const onPersonThumbnail = (personId: string) => {
|
||||||
assetFaceGenerated.push(personId);
|
assetFaceGenerated.push(personId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isEqual(assetFaceGenerated, peopleToCreate) &&
|
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||||
|
isEqual(assetFaceGenerated, mapFacesToBeCreated) &&
|
||||||
loaderLoadingDoneTimeout &&
|
loaderLoadingDoneTimeout &&
|
||||||
automaticRefreshTimeout &&
|
automaticRefreshTimeout
|
||||||
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
|
|
||||||
) {
|
) {
|
||||||
clearTimeout(loaderLoadingDoneTimeout);
|
clearTimeout(loaderLoadingDoneTimeout);
|
||||||
clearTimeout(automaticRefreshTimeout);
|
clearTimeout(automaticRefreshTimeout);
|
||||||
dispatch('refresh');
|
onRefresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,10 +130,6 @@
|
|||||||
return b.every((valueB) => a.includes(valueB));
|
return b.every((valueB) => a.includes(valueB));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackButton = () => {
|
|
||||||
dispatch('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = (id: string) => {
|
const handleReset = (id: string) => {
|
||||||
if (selectedPersonToReassign[id]) {
|
if (selectedPersonToReassign[id]) {
|
||||||
delete selectedPersonToReassign[id];
|
delete selectedPersonToReassign[id];
|
||||||
@@ -113,9 +145,22 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveFace = (face: AssetFaceResponseDto) => {
|
||||||
|
selectedFaceToRemove[face.id] = face;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbortRemove = (id: string) => {
|
||||||
|
delete selectedFaceToRemove[id];
|
||||||
|
selectedFaceToRemove = selectedFaceToRemove;
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditFaces = async () => {
|
const handleEditFaces = async () => {
|
||||||
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
||||||
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
|
const numberOfChanges =
|
||||||
|
Object.keys(selectedPersonToCreate).length +
|
||||||
|
Object.keys(selectedPersonToReassign).length +
|
||||||
|
Object.keys(selectedFaceToRemove).length +
|
||||||
|
Object.keys(selectedPersonToAdd).length;
|
||||||
|
|
||||||
if (numberOfChanges > 0) {
|
if (numberOfChanges > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -137,6 +182,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [id, face] of Object.entries(selectedPersonToAdd)) {
|
||||||
|
if (face.person) {
|
||||||
|
await reassignFacesById({
|
||||||
|
id: face.person.id,
|
||||||
|
faceDto: { id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const data = await createPerson({ personCreateDto: {} });
|
||||||
|
peopleToCreate.push(data.id);
|
||||||
|
await reassignFacesById({
|
||||||
|
id: data.id,
|
||||||
|
faceDto: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id] of Object.entries(selectedFaceToRemove)) {
|
||||||
|
await unassignFace({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
|
message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
@@ -149,9 +216,9 @@
|
|||||||
isShowLoadingDone = false;
|
isShowLoadingDone = false;
|
||||||
if (peopleToCreate.length === 0) {
|
if (peopleToCreate.length === 0) {
|
||||||
clearTimeout(loaderLoadingDoneTimeout);
|
clearTimeout(loaderLoadingDoneTimeout);
|
||||||
dispatch('refresh');
|
onRefresh();
|
||||||
} else {
|
} else {
|
||||||
automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15_000);
|
automaticRefreshTimeout = setTimeout(() => onRefresh(), 15_000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +243,27 @@
|
|||||||
showSelectedFaces = true;
|
showSelectedFaces = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleCreateOrReassignFaceFromUnassignedFace = (face: FaceWithGeneratedThumbnail) => {
|
||||||
|
selectedPersonToAdd[face.id] = face;
|
||||||
|
selectedPersonToAdd = selectedPersonToAdd;
|
||||||
|
showUnassignedFaces = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAvailableFaces = () => {
|
||||||
|
showUnassignedFaces = !showUnassignedFaces;
|
||||||
|
};
|
||||||
|
const handleRemoveAddedFace = (face: FaceWithGeneratedThumbnail) => {
|
||||||
|
$boundingBoxesArray = [];
|
||||||
|
delete selectedPersonToAdd[face.id];
|
||||||
|
|
||||||
|
// trigger reactivity
|
||||||
|
selectedPersonToAdd = selectedPersonToAdd;
|
||||||
|
};
|
||||||
|
const handleRemoveAllFaces = () => {
|
||||||
|
for (const face of peopleWithFaces) {
|
||||||
|
selectedFaceToRemove[face.id] = face;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -184,130 +272,252 @@
|
|||||||
>
|
>
|
||||||
<div class="flex place-items-center justify-between gap-2">
|
<div class="flex place-items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
|
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={onClose} />
|
||||||
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
|
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
|
||||||
</div>
|
</div>
|
||||||
{#if !isShowLoadingDone}
|
{#if !isShowLoadingDone}
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
type="button"
|
{#if peopleWithFaces.length > Object.keys(selectedFaceToRemove).length}
|
||||||
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
<button
|
||||||
on:click={() => handleEditFaces()}
|
type="button"
|
||||||
>
|
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||||
Done
|
on:click={handleRemoveAllFaces}
|
||||||
</button>
|
title="Remove all faces"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon path={mdiClose} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if (unassignedFaces.length > 0 && unassignedFaces.length > Object.keys(selectedPersonToAdd).length) || Object.keys(selectedFaceToRemove).length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||||
|
on:click={handleOpenAvailableFaces}
|
||||||
|
title="Faces available"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon path={mdiFaceMan} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||||
|
on:click={() => handleEditFaces()}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if peopleWithFaces.length > 0}
|
||||||
<div class="px-4 py-4 text-sm">
|
<div class="px-4 py-4 text-sm">
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
{#if isShowLoadingPeople}
|
{#if isShowLoadingPeople}
|
||||||
<div class="flex w-full justify-center">
|
<div class="flex w-full justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each peopleWithFaces as face, index}
|
{#each peopleWithFaces as face, index}
|
||||||
{#if face.person}
|
{#if face.person && !selectedFaceToRemove[face.id]}
|
||||||
<div class="relative z-[20001] h-[115px] w-[95px]">
|
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex={index}
|
tabindex={index}
|
||||||
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
||||||
on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||||
on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if selectedPersonToCreate[face.id]}
|
{#if selectedPersonToCreate[face.id]}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
curve
|
curve
|
||||||
shadow
|
shadow
|
||||||
url={selectedPersonToCreate[face.id]}
|
url={selectedPersonToCreate[face.id]}
|
||||||
altText={selectedPersonToCreate[face.id]}
|
altText={selectedPersonToCreate[face.id]}
|
||||||
title={'New person'}
|
title={'New person'}
|
||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
/>
|
/>
|
||||||
{:else if selectedPersonToReassign[face.id]}
|
{:else if selectedPersonToReassign[face.id]}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
curve
|
curve
|
||||||
shadow
|
shadow
|
||||||
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
||||||
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
||||||
title={getPersonNameWithHiddenValue(
|
title={getPersonNameWithHiddenValue(
|
||||||
selectedPersonToReassign[face.id].name,
|
selectedPersonToReassign[face.id].name,
|
||||||
face.person?.isHidden,
|
face.person?.isHidden,
|
||||||
)}
|
)}
|
||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<ImageThumbnail
|
|
||||||
curve
|
|
||||||
shadow
|
|
||||||
url={getPeopleThumbnailUrl(face.person.id)}
|
|
||||||
altText={face.person.name || face.person.id}
|
|
||||||
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
|
||||||
widthStyle={thumbnailWidth}
|
|
||||||
heightStyle={thumbnailWidth}
|
|
||||||
hidden={face.person.isHidden}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !selectedPersonToCreate[face.id]}
|
|
||||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
|
||||||
{#if selectedPersonToReassign[face.id]?.id}
|
|
||||||
{selectedPersonToReassign[face.id]?.name}
|
|
||||||
{:else}
|
{:else}
|
||||||
{face.person?.name}
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(face.person.id)}
|
||||||
|
altText={face.person.name || face.person.id}
|
||||||
|
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
||||||
|
widthStyle={thumbnailWidth}
|
||||||
|
heightStyle={thumbnailWidth}
|
||||||
|
hidden={face.person.isHidden}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full">
|
{#if !selectedPersonToCreate[face.id]}
|
||||||
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||||
<CircleIconButton
|
{#if selectedPersonToReassign[face.id]?.id}
|
||||||
color="primary"
|
{selectedPersonToReassign[face.id]?.name}
|
||||||
icon={mdiRestart}
|
{:else}
|
||||||
title="Reset"
|
{face.person?.name}
|
||||||
size="18"
|
{/if}
|
||||||
padding="1"
|
</p>
|
||||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
|
||||||
on:click={() => handleReset(face.id)}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<CircleIconButton
|
|
||||||
color="primary"
|
|
||||||
icon={mdiMinus}
|
|
||||||
title="Select new face"
|
|
||||||
size="18"
|
|
||||||
padding="1"
|
|
||||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
|
||||||
on:click={() => handleFacePicker(face)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id]}
|
||||||
|
<div class="absolute -left-[8px] -bottom-[8px] h-[20px] w-[20px]">
|
||||||
|
<CircleIconButton
|
||||||
|
color="red"
|
||||||
|
icon={mdiClose}
|
||||||
|
title="Reset"
|
||||||
|
size="20"
|
||||||
|
buttonSize="20"
|
||||||
|
padding="[1px]"
|
||||||
|
disableHover
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
on:click={() => handleRemoveFace(face)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||||
|
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
||||||
|
<CircleIconButton
|
||||||
|
color="blue"
|
||||||
|
icon={mdiRestart}
|
||||||
|
title="Reset"
|
||||||
|
size="20"
|
||||||
|
buttonSize="20"
|
||||||
|
padding="[1px]"
|
||||||
|
disableHover
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
on:click={() => handleReset(face.id)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<CircleIconButton
|
||||||
|
color="blue"
|
||||||
|
icon={mdiMinus}
|
||||||
|
title="Select new face"
|
||||||
|
size="20"
|
||||||
|
buttonSize="20"
|
||||||
|
disableHover
|
||||||
|
padding="[1px]"
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
||||||
|
on:click={() => handleFacePicker(face)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/each}
|
||||||
{/each}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="grid place-items-center">
|
||||||
|
<Icon path={mdiAccountOff} size="3.5em" />
|
||||||
|
<p class="mt-5 font-medium">No visible faces</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="px-4 py-4 text-sm">
|
||||||
|
{#if Object.keys(selectedPersonToAdd).length > 0}
|
||||||
|
<div class="mt-8">
|
||||||
|
<p>Faces to add</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{#each Object.entries(selectedPersonToAdd) as [_, face], index}
|
||||||
|
{#if face}
|
||||||
|
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex={index}
|
||||||
|
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
||||||
|
on:focus={() => ($boundingBoxesArray = [face])}
|
||||||
|
on:mouseover={() => ($boundingBoxesArray = [face])}
|
||||||
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={face.person ? getPeopleThumbnailUrl(face.person.id) : face.customThumbnail}
|
||||||
|
altText={'New person'}
|
||||||
|
title={'New person'}
|
||||||
|
widthStyle="90px"
|
||||||
|
heightStyle="90px"
|
||||||
|
thumbhash={null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if face.person?.name}
|
||||||
|
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||||
|
{face.person?.name}
|
||||||
|
</p>{/if}
|
||||||
|
|
||||||
|
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||||
|
<CircleIconButton
|
||||||
|
color="red"
|
||||||
|
icon={mdiMinus}
|
||||||
|
title="Reset"
|
||||||
|
size="20"
|
||||||
|
buttonSize="20"
|
||||||
|
padding="[1px]"
|
||||||
|
disableHover
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
on:click={() => handleRemoveAddedFace(face)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if showSelectedFaces}
|
{#if showSelectedFaces}
|
||||||
<AssignFaceSidePanel
|
<AssignFaceSidePanel
|
||||||
{peopleWithFaces}
|
{editedFace}
|
||||||
{allPeople}
|
{allPeople}
|
||||||
{editedPerson}
|
{editedPerson}
|
||||||
{assetType}
|
{assetType}
|
||||||
{assetId}
|
{assetId}
|
||||||
on:close={() => (showSelectedFaces = false)}
|
onClose={() => (showSelectedFaces = false)}
|
||||||
on:createPerson={(event) => handleCreatePerson(event.detail)}
|
onCreatePerson={handleCreatePerson}
|
||||||
on:reassign={(event) => handleReassignFace(event.detail)}
|
onReassign={handleReassignFace}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showUnassignedFaces}
|
||||||
|
<UnassignedFacesSidePanel
|
||||||
|
{assetType}
|
||||||
|
{assetId}
|
||||||
|
{allPeople}
|
||||||
|
{unassignedFaces}
|
||||||
|
{selectedPersonToAdd}
|
||||||
|
{selectedFaceToRemove}
|
||||||
|
onResetFacesToBeRemoved={() => (selectedFaceToRemove = selectedFaceToRemove)}
|
||||||
|
onClose={() => (showUnassignedFaces = false)}
|
||||||
|
onCreatePerson={handleCreateOrReassignFaceFromUnassignedFace}
|
||||||
|
onReassign={handleCreateOrReassignFaceFromUnassignedFace}
|
||||||
|
onAbortRemove={handleAbortRemove}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { linear } from 'svelte/easing';
|
||||||
|
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiMinus } from '@mdi/js';
|
||||||
|
import type { FaceWithGeneratedThumbnail } from '$lib/utils/people-utils';
|
||||||
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
|
import type { AssetFaceResponseDto, AssetTypeEnum, PersonResponseDto } from '@immich/sdk';
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import AssignFaceSidePanel from '$lib/components/faces-page/assign-face-side-panel.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
|
|
||||||
|
export let unassignedFaces: FaceWithGeneratedThumbnail[];
|
||||||
|
export let allPeople: PersonResponseDto[];
|
||||||
|
export let selectedPersonToAdd: Record<string, FaceWithGeneratedThumbnail>;
|
||||||
|
export let selectedFaceToRemove: Record<string, AssetFaceResponseDto>;
|
||||||
|
export let assetType: AssetTypeEnum;
|
||||||
|
export let assetId: string;
|
||||||
|
export let onResetFacesToBeRemoved: () => void;
|
||||||
|
export let onClose: () => void;
|
||||||
|
export let onCreatePerson: (face: FaceWithGeneratedThumbnail) => void;
|
||||||
|
export let onReassign: (face: FaceWithGeneratedThumbnail) => void;
|
||||||
|
export let onAbortRemove: (id: string) => void;
|
||||||
|
|
||||||
|
let showSelectedFaces = false;
|
||||||
|
let editedFace: FaceWithGeneratedThumbnail;
|
||||||
|
|
||||||
|
const handleSelectedFace = (face: FaceWithGeneratedThumbnail) => {
|
||||||
|
editedFace = face;
|
||||||
|
showSelectedFaces = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
||||||
|
showSelectedFaces = false;
|
||||||
|
if (newFeaturePhoto) {
|
||||||
|
editedFace.customThumbnail = newFeaturePhoto;
|
||||||
|
onCreatePerson(editedFace);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReassignFace = (person: PersonResponseDto | null) => {
|
||||||
|
if (person) {
|
||||||
|
showSelectedFaces = false;
|
||||||
|
editedFace.person = person;
|
||||||
|
onReassign(editedFace);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbortRemove = (id: string) => {
|
||||||
|
delete selectedFaceToRemove[id];
|
||||||
|
selectedFaceToRemove = selectedFaceToRemove;
|
||||||
|
onAbortRemove(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAllFaces = () => {
|
||||||
|
for (const [id] of Object.entries(selectedFaceToRemove)) {
|
||||||
|
delete selectedFaceToRemove[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigger reactivity
|
||||||
|
selectedFaceToRemove = selectedFaceToRemove;
|
||||||
|
onResetFacesToBeRemoved();
|
||||||
|
};
|
||||||
|
</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 bg-immich-bg 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
|
||||||
|
type="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={onClose}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon path={mdiArrowLeftThin} size="24" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Faces available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if unassignedFaces.length > 0 && unassignedFaces.length > Object.keys(selectedPersonToAdd).length}
|
||||||
|
<div class="px-4 py-4 text-sm">
|
||||||
|
<p>Faces removed</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{#each unassignedFaces as face, index (face.id)}
|
||||||
|
{#if !selectedPersonToAdd[face.id]}
|
||||||
|
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex={index}
|
||||||
|
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
||||||
|
on:focus={() => ($boundingBoxesArray = [face])}
|
||||||
|
on:mouseover={() => ($boundingBoxesArray = [face])}
|
||||||
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={face.customThumbnail}
|
||||||
|
title="Available face"
|
||||||
|
altText="Available face"
|
||||||
|
widthStyle="90px"
|
||||||
|
heightStyle="90px"
|
||||||
|
thumbhash={null}
|
||||||
|
/>
|
||||||
|
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||||
|
<CircleIconButton
|
||||||
|
color="blue"
|
||||||
|
icon={mdiMinus}
|
||||||
|
title="Reset"
|
||||||
|
size="20"
|
||||||
|
buttonSize="20"
|
||||||
|
padding="[1px]"
|
||||||
|
disableHover
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
on:click={() => handleSelectedFace(face)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="grid place-items-center">
|
||||||
|
<Icon path={mdiAccountOff} size="3.5em" />
|
||||||
|
<p class="mt-5 font-medium">No faces removed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if Object.keys(selectedFaceToRemove).length > 0}
|
||||||
|
<div class="px-4 py-4 text-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p>Faces to be removed</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||||
|
on:click={handleRemoveAllFaces}
|
||||||
|
title="Reset"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon path={mdiClose} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
{#each Object.entries(selectedFaceToRemove) as [id, face], index}
|
||||||
|
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex={index}
|
||||||
|
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
||||||
|
on:focus={() => (face ? ($boundingBoxesArray = [face]) : '')}
|
||||||
|
on:mouseover={() => (face ? ($boundingBoxesArray = [face]) : '')}
|
||||||
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={face.person ? getPeopleThumbnailUrl(face.person?.id) : ''}
|
||||||
|
title="Available face"
|
||||||
|
altText="Available face"
|
||||||
|
widthStyle="90px"
|
||||||
|
heightStyle="90px"
|
||||||
|
thumbhash={null}
|
||||||
|
/>
|
||||||
|
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||||
|
<CircleIconButton
|
||||||
|
color="blue"
|
||||||
|
icon={mdiClose}
|
||||||
|
title="Reset"
|
||||||
|
size="20"
|
||||||
|
buttonSize="20"
|
||||||
|
padding="[1px]"
|
||||||
|
disableHover
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||||
|
on:click={() => handleAbortRemove(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if showSelectedFaces}
|
||||||
|
<AssignFaceSidePanel
|
||||||
|
{assetType}
|
||||||
|
{assetId}
|
||||||
|
{editedFace}
|
||||||
|
{allPeople}
|
||||||
|
onClose={() => (showSelectedFaces = false)}
|
||||||
|
onCreatePerson={handleCreatePerson}
|
||||||
|
onReassign={handleReassignFace}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -6,10 +6,11 @@
|
|||||||
createPerson,
|
createPerson,
|
||||||
getAllPeople,
|
getAllPeople,
|
||||||
reassignFaces,
|
reassignFaces,
|
||||||
|
unassignFaces,
|
||||||
type AssetFaceUpdateItem,
|
type AssetFaceUpdateItem,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { mdiMerge, mdiPlus } from '@mdi/js';
|
import { mdiMerge, mdiPlus, mdiTagRemove } from '@mdi/js';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import { quintOut } from 'svelte/easing';
|
import { quintOut } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
let disableButtons = false;
|
let disableButtons = false;
|
||||||
let showLoadingSpinnerCreate = false;
|
let showLoadingSpinnerCreate = false;
|
||||||
let showLoadingSpinnerReassign = false;
|
let showLoadingSpinnerReassign = false;
|
||||||
|
let showLoadingSpinnerUnassign = false;
|
||||||
let hasSelection = false;
|
let hasSelection = false;
|
||||||
let screenHeight: number;
|
let screenHeight: number;
|
||||||
|
|
||||||
@@ -112,6 +114,24 @@
|
|||||||
showLoadingSpinnerReassign = false;
|
showLoadingSpinnerReassign = false;
|
||||||
dispatch('confirm');
|
dispatch('confirm');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnassign = async () => {
|
||||||
|
const timeout = setTimeout(() => (showLoadingSpinnerUnassign = true), 100);
|
||||||
|
try {
|
||||||
|
disableButtons = true;
|
||||||
|
await unassignFaces({ assetFaceUpdateDto: { data: selectedPeople } });
|
||||||
|
notificationController.show({
|
||||||
|
message: `Un-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''}`,
|
||||||
|
type: NotificationType.Info,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Unable to unassign assets');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
showLoadingSpinnerCreate = false;
|
||||||
|
dispatch('confirm');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window bind:innerHeight={screenHeight} />
|
<svelte:window bind:innerHeight={screenHeight} />
|
||||||
@@ -127,16 +147,29 @@
|
|||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
<svelte:fragment slot="trailing">
|
<svelte:fragment slot="trailing">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
<Button
|
||||||
|
title={'Unassign selected assets to a new person'}
|
||||||
|
size={'sm'}
|
||||||
|
disabled={disableButtons || hasSelection}
|
||||||
|
on:click={handleUnassign}
|
||||||
|
>
|
||||||
|
{#if showLoadingSpinnerUnassign}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:else}
|
||||||
|
<Icon path={mdiTagRemove} size={18} />
|
||||||
|
{/if}
|
||||||
|
<span class="ml-2"> Unassign</span></Button
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
title={'Assign selected assets to a new person'}
|
title={'Assign selected assets to a new person'}
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
disabled={disableButtons || hasSelection}
|
disabled={disableButtons || hasSelection}
|
||||||
on:click={handleCreate}
|
on:click={handleCreate}
|
||||||
>
|
>
|
||||||
{#if !showLoadingSpinnerCreate}
|
{#if showLoadingSpinnerCreate}
|
||||||
<Icon path={mdiPlus} size={18} />
|
|
||||||
{:else}
|
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
{:else}
|
||||||
|
<Icon path={mdiPlus} size={18} />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="ml-2"> Create new Person</span></Button
|
<span class="ml-2"> Create new Person</span></Button
|
||||||
>
|
>
|
||||||
@@ -146,12 +179,12 @@
|
|||||||
disabled={disableButtons || !hasSelection}
|
disabled={disableButtons || !hasSelection}
|
||||||
on:click={handleReassign}
|
on:click={handleReassign}
|
||||||
>
|
>
|
||||||
{#if !showLoadingSpinnerReassign}
|
{#if showLoadingSpinnerReassign}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<Icon path={mdiMerge} size={18} class="rotate-180" />
|
<Icon path={mdiMerge} size={18} class="rotate-180" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<LoadingSpinner />
|
|
||||||
{/if}
|
{/if}
|
||||||
<span class="ml-2"> Reassign</span></Button
|
<span class="ml-2"> Reassign</span></Button
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Faces } from '$lib/stores/people.store';
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
|
import type { AssetFaceResponseDto } from '@immich/sdk';
|
||||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||||
|
|
||||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||||
@@ -19,6 +20,10 @@ export interface boundingBox {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FaceWithGeneratedThumbnail extends AssetFaceResponseDto {
|
||||||
|
customThumbnail: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const getBoundingBox = (
|
export const getBoundingBox = (
|
||||||
faces: Faces[],
|
faces: Faces[],
|
||||||
zoom: ZoomImageWheelState,
|
zoom: ZoomImageWheelState,
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { PersonResponseDto } from '@immich/sdk';
|
import { photoViewer } from '$lib/stores/assets.store';
|
||||||
|
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||||
|
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
export const searchNameLocal = (
|
export const searchNameLocal = (
|
||||||
name: string,
|
name: string,
|
||||||
@@ -28,3 +31,60 @@ export const searchNameLocal = (
|
|||||||
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
|
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
|
||||||
return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
|
return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const zoomImageToBase64 = async (
|
||||||
|
face: AssetFaceResponseDto,
|
||||||
|
assetType: AssetTypeEnum,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
let image: HTMLImageElement | null = null;
|
||||||
|
if (assetType === AssetTypeEnum.Image) {
|
||||||
|
image = get(photoViewer);
|
||||||
|
} else if (assetType === AssetTypeEnum.Video) {
|
||||||
|
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
||||||
|
const img: HTMLImageElement = new Image();
|
||||||
|
img.src = data;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
img.addEventListener('load', () => resolve());
|
||||||
|
img.addEventListener('error', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
image = img;
|
||||||
|
}
|
||||||
|
if (image === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
|
||||||
|
|
||||||
|
const coordinates = {
|
||||||
|
x1: (image.naturalWidth / imageWidth) * x1,
|
||||||
|
x2: (image.naturalWidth / imageWidth) * x2,
|
||||||
|
y1: (image.naturalHeight / imageHeight) * y1,
|
||||||
|
y2: (image.naturalHeight / imageHeight) * y2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const faceWidth = coordinates.x2 - coordinates.x1;
|
||||||
|
const faceHeight = coordinates.y2 - coordinates.y1;
|
||||||
|
|
||||||
|
const faceImage = new Image();
|
||||||
|
faceImage.src = image.src;
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
faceImage.addEventListener('load', resolve);
|
||||||
|
faceImage.addEventListener('error', () => resolve(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = faceWidth;
|
||||||
|
canvas.height = faceHeight;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (context) {
|
||||||
|
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
||||||
|
|
||||||
|
return canvas.toDataURL();
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function getAltText(asset: AssetResponseDto) {
|
|||||||
altText += ` in ${asset.exifInfo.city}, ${asset.exifInfo.country}`;
|
altText += ` in ${asset.exifInfo.city}, ${asset.exifInfo.country}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? [];
|
const names = asset.people?.visiblePeople.filter((p) => p.name).map((p) => p.name) ?? [];
|
||||||
if (names.length == 1) {
|
if (names.length == 1) {
|
||||||
altText += ` with ${names[0]}`;
|
altText += ` with ${names[0]}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user