You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-06-16 03:40:33 +02:00
feat(web): manual face tagging and deletion (#16062)
This commit is contained in:
@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"delete_face": "Delete face",
|
||||||
|
"tag_people": "Tag People",
|
||||||
|
"error_delete_face": "Error deleting face from asset",
|
||||||
"search_by_description_example": "Hiking day in Sapa",
|
"search_by_description_example": "Hiking day in Sapa",
|
||||||
|
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
|
||||||
"search_by_description": "Search by description",
|
"search_by_description": "Search by description",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
|
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@ -118,6 +118,8 @@ Class | Method | HTTP request | Description
|
|||||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
|
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
|
||||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
|
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
|
||||||
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
|
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
|
||||||
|
*FacesApi* | [**createFace**](doc//FacesApi.md#createface) | **POST** /faces |
|
||||||
|
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} |
|
||||||
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
|
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
|
||||||
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
|
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
|
||||||
*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix |
|
*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix |
|
||||||
@ -278,6 +280,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
|
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
|
||||||
- [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md)
|
- [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md)
|
||||||
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
|
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
|
||||||
|
- [AssetFaceCreateDto](doc//AssetFaceCreateDto.md)
|
||||||
|
- [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md)
|
||||||
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
|
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
|
||||||
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
|
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
|
||||||
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
|
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
|
||||||
|
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
@ -87,6 +87,8 @@ part 'model/asset_bulk_upload_check_response_dto.dart';
|
|||||||
part 'model/asset_bulk_upload_check_result.dart';
|
part 'model/asset_bulk_upload_check_result.dart';
|
||||||
part 'model/asset_delta_sync_dto.dart';
|
part 'model/asset_delta_sync_dto.dart';
|
||||||
part 'model/asset_delta_sync_response_dto.dart';
|
part 'model/asset_delta_sync_response_dto.dart';
|
||||||
|
part 'model/asset_face_create_dto.dart';
|
||||||
|
part 'model/asset_face_delete_dto.dart';
|
||||||
part 'model/asset_face_response_dto.dart';
|
part 'model/asset_face_response_dto.dart';
|
||||||
part 'model/asset_face_update_dto.dart';
|
part 'model/asset_face_update_dto.dart';
|
||||||
part 'model/asset_face_update_item.dart';
|
part 'model/asset_face_update_item.dart';
|
||||||
|
83
mobile/openapi/lib/api/faces_api.dart
generated
83
mobile/openapi/lib/api/faces_api.dart
generated
@ -16,6 +16,89 @@ class FacesApi {
|
|||||||
|
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
/// Performs an HTTP 'POST /faces' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AssetFaceCreateDto] assetFaceCreateDto (required):
|
||||||
|
Future<Response> createFaceWithHttpInfo(AssetFaceCreateDto assetFaceCreateDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/faces';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = assetFaceCreateDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'POST',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [AssetFaceCreateDto] assetFaceCreateDto (required):
|
||||||
|
Future<void> createFace(AssetFaceCreateDto assetFaceCreateDto,) async {
|
||||||
|
final response = await createFaceWithHttpInfo(assetFaceCreateDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'DELETE /faces/{id}' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
///
|
||||||
|
/// * [AssetFaceDeleteDto] assetFaceDeleteDto (required):
|
||||||
|
Future<Response> deleteFaceWithHttpInfo(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/faces/{id}'
|
||||||
|
.replaceAll('{id}', id);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = assetFaceDeleteDto;
|
||||||
|
|
||||||
|
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:
|
||||||
|
///
|
||||||
|
/// * [String] id (required):
|
||||||
|
///
|
||||||
|
/// * [AssetFaceDeleteDto] assetFaceDeleteDto (required):
|
||||||
|
Future<void> deleteFace(String id, AssetFaceDeleteDto assetFaceDeleteDto,) async {
|
||||||
|
final response = await deleteFaceWithHttpInfo(id, assetFaceDeleteDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /faces' operation and returns the [Response].
|
/// Performs an HTTP 'GET /faces' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
|
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@ -230,6 +230,10 @@ class ApiClient {
|
|||||||
return AssetDeltaSyncDto.fromJson(value);
|
return AssetDeltaSyncDto.fromJson(value);
|
||||||
case 'AssetDeltaSyncResponseDto':
|
case 'AssetDeltaSyncResponseDto':
|
||||||
return AssetDeltaSyncResponseDto.fromJson(value);
|
return AssetDeltaSyncResponseDto.fromJson(value);
|
||||||
|
case 'AssetFaceCreateDto':
|
||||||
|
return AssetFaceCreateDto.fromJson(value);
|
||||||
|
case 'AssetFaceDeleteDto':
|
||||||
|
return AssetFaceDeleteDto.fromJson(value);
|
||||||
case 'AssetFaceResponseDto':
|
case 'AssetFaceResponseDto':
|
||||||
return AssetFaceResponseDto.fromJson(value);
|
return AssetFaceResponseDto.fromJson(value);
|
||||||
case 'AssetFaceUpdateDto':
|
case 'AssetFaceUpdateDto':
|
||||||
|
155
mobile/openapi/lib/model/asset_face_create_dto.dart
generated
Normal file
155
mobile/openapi/lib/model/asset_face_create_dto.dart
generated
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// 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 AssetFaceCreateDto {
|
||||||
|
/// Returns a new [AssetFaceCreateDto] instance.
|
||||||
|
AssetFaceCreateDto({
|
||||||
|
required this.assetId,
|
||||||
|
required this.height,
|
||||||
|
required this.imageHeight,
|
||||||
|
required this.imageWidth,
|
||||||
|
required this.personId,
|
||||||
|
required this.width,
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
String assetId;
|
||||||
|
|
||||||
|
int height;
|
||||||
|
|
||||||
|
int imageHeight;
|
||||||
|
|
||||||
|
int imageWidth;
|
||||||
|
|
||||||
|
String personId;
|
||||||
|
|
||||||
|
int width;
|
||||||
|
|
||||||
|
int x;
|
||||||
|
|
||||||
|
int y;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AssetFaceCreateDto &&
|
||||||
|
other.assetId == assetId &&
|
||||||
|
other.height == height &&
|
||||||
|
other.imageHeight == imageHeight &&
|
||||||
|
other.imageWidth == imageWidth &&
|
||||||
|
other.personId == personId &&
|
||||||
|
other.width == width &&
|
||||||
|
other.x == x &&
|
||||||
|
other.y == y;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(assetId.hashCode) +
|
||||||
|
(height.hashCode) +
|
||||||
|
(imageHeight.hashCode) +
|
||||||
|
(imageWidth.hashCode) +
|
||||||
|
(personId.hashCode) +
|
||||||
|
(width.hashCode) +
|
||||||
|
(x.hashCode) +
|
||||||
|
(y.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'assetId'] = this.assetId;
|
||||||
|
json[r'height'] = this.height;
|
||||||
|
json[r'imageHeight'] = this.imageHeight;
|
||||||
|
json[r'imageWidth'] = this.imageWidth;
|
||||||
|
json[r'personId'] = this.personId;
|
||||||
|
json[r'width'] = this.width;
|
||||||
|
json[r'x'] = this.x;
|
||||||
|
json[r'y'] = this.y;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AssetFaceCreateDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AssetFaceCreateDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AssetFaceCreateDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AssetFaceCreateDto(
|
||||||
|
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||||
|
height: mapValueOfType<int>(json, r'height')!,
|
||||||
|
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
|
||||||
|
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
|
||||||
|
personId: mapValueOfType<String>(json, r'personId')!,
|
||||||
|
width: mapValueOfType<int>(json, r'width')!,
|
||||||
|
x: mapValueOfType<int>(json, r'x')!,
|
||||||
|
y: mapValueOfType<int>(json, r'y')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AssetFaceCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AssetFaceCreateDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AssetFaceCreateDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AssetFaceCreateDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AssetFaceCreateDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AssetFaceCreateDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AssetFaceCreateDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AssetFaceCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AssetFaceCreateDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AssetFaceCreateDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'assetId',
|
||||||
|
'height',
|
||||||
|
'imageHeight',
|
||||||
|
'imageWidth',
|
||||||
|
'personId',
|
||||||
|
'width',
|
||||||
|
'x',
|
||||||
|
'y',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
99
mobile/openapi/lib/model/asset_face_delete_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/asset_face_delete_dto.dart
generated
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// 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 AssetFaceDeleteDto {
|
||||||
|
/// Returns a new [AssetFaceDeleteDto] instance.
|
||||||
|
AssetFaceDeleteDto({
|
||||||
|
required this.force,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool force;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AssetFaceDeleteDto &&
|
||||||
|
other.force == force;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(force.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AssetFaceDeleteDto[force=$force]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'force'] = this.force;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AssetFaceDeleteDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AssetFaceDeleteDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AssetFaceDeleteDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AssetFaceDeleteDto(
|
||||||
|
force: mapValueOfType<bool>(json, r'force')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AssetFaceDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AssetFaceDeleteDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AssetFaceDeleteDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AssetFaceDeleteDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AssetFaceDeleteDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AssetFaceDeleteDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AssetFaceDeleteDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AssetFaceDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AssetFaceDeleteDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AssetFaceDeleteDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'force',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2428,9 +2428,85 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Faces"
|
"Faces"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "createFace",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetFaceCreateDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Faces"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/faces/{id}": {
|
"/faces/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "deleteFace",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetFaceDeleteDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Faces"
|
||||||
|
]
|
||||||
|
},
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "reassignFacesById",
|
"operationId": "reassignFacesById",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
@ -8172,6 +8248,58 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"AssetFaceCreateDto": {
|
||||||
|
"properties": {
|
||||||
|
"assetId": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"imageHeight": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"imageWidth": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"personId": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"x": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"assetId",
|
||||||
|
"height",
|
||||||
|
"imageHeight",
|
||||||
|
"imageWidth",
|
||||||
|
"personId",
|
||||||
|
"width",
|
||||||
|
"x",
|
||||||
|
"y"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"AssetFaceDeleteDto": {
|
||||||
|
"properties": {
|
||||||
|
"force": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"force"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AssetFaceResponseDto": {
|
"AssetFaceResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"boundingBoxX1": {
|
"boundingBoxX1": {
|
||||||
|
@ -523,6 +523,19 @@ export type AssetFaceResponseDto = {
|
|||||||
person: (PersonResponseDto) | null;
|
person: (PersonResponseDto) | null;
|
||||||
sourceType?: SourceType;
|
sourceType?: SourceType;
|
||||||
};
|
};
|
||||||
|
export type AssetFaceCreateDto = {
|
||||||
|
assetId: string;
|
||||||
|
height: number;
|
||||||
|
imageHeight: number;
|
||||||
|
imageWidth: number;
|
||||||
|
personId: string;
|
||||||
|
width: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
export type AssetFaceDeleteDto = {
|
||||||
|
force: boolean;
|
||||||
|
};
|
||||||
export type FaceDto = {
|
export type FaceDto = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
@ -2029,6 +2042,25 @@ export function getFaces({ id }: {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export function createFace({ assetFaceCreateDto }: {
|
||||||
|
assetFaceCreateDto: AssetFaceCreateDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/faces", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "POST",
|
||||||
|
body: assetFaceCreateDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
export function deleteFace({ id, assetFaceDeleteDto }: {
|
||||||
|
id: string;
|
||||||
|
assetFaceDeleteDto: AssetFaceDeleteDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText(`/faces/${encodeURIComponent(id)}`, oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "DELETE",
|
||||||
|
body: assetFaceDeleteDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
export function reassignFacesById({ id, faceDto }: {
|
export function reassignFacesById({ id, faceDto }: {
|
||||||
id: string;
|
id: string;
|
||||||
faceDto: FaceDto;
|
faceDto: FaceDto;
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, 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 {
|
||||||
|
AssetFaceCreateDto,
|
||||||
|
AssetFaceDeleteDto,
|
||||||
|
AssetFaceResponseDto,
|
||||||
|
FaceDto,
|
||||||
|
PersonResponseDto,
|
||||||
|
} from 'src/dtos/person.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
@ -12,6 +18,12 @@ import { UUIDParamDto } from 'src/validation';
|
|||||||
export class FaceController {
|
export class FaceController {
|
||||||
constructor(private service: PersonService) {}
|
constructor(private service: PersonService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Authenticated({ permission: Permission.FACE_CREATE })
|
||||||
|
createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) {
|
||||||
|
return this.service.createFace(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Authenticated({ permission: Permission.FACE_READ })
|
@Authenticated({ permission: Permission.FACE_READ })
|
||||||
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
|
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
|
||||||
@ -27,4 +39,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({ permission: Permission.FACE_DELETE })
|
||||||
|
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) {
|
||||||
|
return this.service.deleteFace(auth, id, dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
7
server/src/db.d.ts
vendored
7
server/src/db.d.ts
vendored
@ -88,6 +88,7 @@ export interface AssetFaces {
|
|||||||
boundingBoxX2: Generated<number>;
|
boundingBoxX2: Generated<number>;
|
||||||
boundingBoxY1: Generated<number>;
|
boundingBoxY1: Generated<number>;
|
||||||
boundingBoxY2: Generated<number>;
|
boundingBoxY2: Generated<number>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
imageHeight: Generated<number>;
|
imageHeight: Generated<number>;
|
||||||
imageWidth: Generated<number>;
|
imageWidth: Generated<number>;
|
||||||
@ -334,6 +335,11 @@ export interface SocketIoAttachments {
|
|||||||
payload: Buffer | null;
|
payload: Buffer | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemConfig {
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemMetadata {
|
export interface SystemMetadata {
|
||||||
key: string;
|
key: string;
|
||||||
value: Json;
|
value: Json;
|
||||||
@ -448,6 +454,7 @@ export interface DB {
|
|||||||
shared_links: SharedLinks;
|
shared_links: SharedLinks;
|
||||||
smart_search: SmartSearch;
|
smart_search: SmartSearch;
|
||||||
socket_io_attachments: SocketIoAttachments;
|
socket_io_attachments: SocketIoAttachments;
|
||||||
|
system_config: SystemConfig;
|
||||||
system_metadata: SystemMetadata;
|
system_metadata: SystemMetadata;
|
||||||
tag_asset: TagAsset;
|
tag_asset: TagAsset;
|
||||||
tags: Tags;
|
tags: Tags;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator';
|
import { IsArray, IsInt, IsNotEmpty, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { PropertyLifecycle } from 'src/decorators';
|
import { PropertyLifecycle } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
@ -164,6 +164,43 @@ export class AssetFaceUpdateItem {
|
|||||||
assetId!: string;
|
assetId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AssetFaceCreateDto extends AssetFaceUpdateItem {
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
imageWidth!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
imageHeight!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
x!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
y!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
width!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
height!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetFaceDeleteDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
force!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class PersonStatisticsResponseDto {
|
export class PersonStatisticsResponseDto {
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
assets!: number;
|
assets!: number;
|
||||||
|
@ -50,4 +50,7 @@ export class AssetFaceEntity {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
person!: PersonEntity | null;
|
person!: PersonEntity | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz' })
|
||||||
|
deletedAt!: Date | null;
|
||||||
}
|
}
|
||||||
|
@ -202,10 +202,14 @@ export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
|||||||
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch'));
|
.select((eb) => eb.fn.toJson(eb.table('smart_search')).as('smartSearch'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {
|
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
|
||||||
return jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as(
|
return jsonArrayFrom(
|
||||||
'faces',
|
eb
|
||||||
);
|
.selectFrom('asset_faces')
|
||||||
|
.selectAll()
|
||||||
|
.whereRef('asset_faces.assetId', '=', 'assets.id')
|
||||||
|
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
|
||||||
|
).as('faces');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileType) {
|
export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileType) {
|
||||||
@ -218,11 +222,12 @@ export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileT
|
|||||||
).as('files');
|
).as('files');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>) {
|
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
|
||||||
return eb
|
return eb
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
.leftJoin('person', 'person.id', 'asset_faces.personId')
|
.leftJoin('person', 'person.id', 'asset_faces.personId')
|
||||||
.whereRef('asset_faces.assetId', '=', 'assets.id')
|
.whereRef('asset_faces.assetId', '=', 'assets.id')
|
||||||
|
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null))
|
||||||
.select((eb) =>
|
.select((eb) =>
|
||||||
eb
|
eb
|
||||||
.fn('jsonb_agg', [
|
.fn('jsonb_agg', [
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddDeletedAtColumnToAssetFacesTable1739466714036 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_faces
|
||||||
|
ADD COLUMN "deletedAt" TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_faces
|
||||||
|
DROP COLUMN "deletedAt"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
@ -96,6 +96,7 @@ select
|
|||||||
left join "person" on "person"."id" = "asset_faces"."personId"
|
left join "person" on "person"."id" = "asset_faces"."personId"
|
||||||
where
|
where
|
||||||
"asset_faces"."assetId" = "assets"."id"
|
"asset_faces"."assetId" = "assets"."id"
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
) as "faces",
|
) as "faces",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
|
@ -42,6 +42,8 @@ select
|
|||||||
from
|
from
|
||||||
"person"
|
"person"
|
||||||
left join "asset_faces" on "asset_faces"."personId" = "person"."id"
|
left join "asset_faces" on "asset_faces"."personId" = "person"."id"
|
||||||
|
where
|
||||||
|
"asset_faces"."deletedAt" is null
|
||||||
group by
|
group by
|
||||||
"person"."id"
|
"person"."id"
|
||||||
having
|
having
|
||||||
@ -67,6 +69,7 @@ from
|
|||||||
"asset_faces"
|
"asset_faces"
|
||||||
where
|
where
|
||||||
"asset_faces"."assetId" = $1
|
"asset_faces"."assetId" = $1
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
order by
|
order by
|
||||||
"asset_faces"."boundingBoxX1" asc
|
"asset_faces"."boundingBoxX1" asc
|
||||||
|
|
||||||
@ -90,6 +93,7 @@ from
|
|||||||
"asset_faces"
|
"asset_faces"
|
||||||
where
|
where
|
||||||
"asset_faces"."id" = $1
|
"asset_faces"."id" = $1
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getFaceByIdWithAssets
|
-- PersonRepository.getFaceByIdWithAssets
|
||||||
select
|
select
|
||||||
@ -124,6 +128,7 @@ from
|
|||||||
"asset_faces"
|
"asset_faces"
|
||||||
where
|
where
|
||||||
"asset_faces"."id" = $1
|
"asset_faces"."id" = $1
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.reassignFace
|
-- PersonRepository.reassignFace
|
||||||
update "asset_faces"
|
update "asset_faces"
|
||||||
@ -169,6 +174,8 @@ from
|
|||||||
and "asset_faces"."personId" = $1
|
and "asset_faces"."personId" = $1
|
||||||
and "assets"."isArchived" = $2
|
and "assets"."isArchived" = $2
|
||||||
and "assets"."deletedAt" is null
|
and "assets"."deletedAt" is null
|
||||||
|
where
|
||||||
|
"asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getNumberOfPeople
|
-- PersonRepository.getNumberOfPeople
|
||||||
select
|
select
|
||||||
@ -185,6 +192,7 @@ from
|
|||||||
and "assets"."isArchived" = $2
|
and "assets"."isArchived" = $2
|
||||||
where
|
where
|
||||||
"person"."ownerId" = $3
|
"person"."ownerId" = $3
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.refreshFaces
|
-- PersonRepository.refreshFaces
|
||||||
with
|
with
|
||||||
@ -235,6 +243,7 @@ from
|
|||||||
where
|
where
|
||||||
"asset_faces"."assetId" in ($1)
|
"asset_faces"."assetId" in ($1)
|
||||||
and "asset_faces"."personId" in ($2)
|
and "asset_faces"."personId" in ($2)
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getRandomFace
|
-- PersonRepository.getRandomFace
|
||||||
select
|
select
|
||||||
@ -243,9 +252,22 @@ from
|
|||||||
"asset_faces"
|
"asset_faces"
|
||||||
where
|
where
|
||||||
"asset_faces"."personId" = $1
|
"asset_faces"."personId" = $1
|
||||||
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
-- PersonRepository.getLatestFaceDate
|
-- PersonRepository.getLatestFaceDate
|
||||||
select
|
select
|
||||||
max("asset_job_status"."facesRecognizedAt")::text as "latestDate"
|
max("asset_job_status"."facesRecognizedAt")::text as "latestDate"
|
||||||
from
|
from
|
||||||
"asset_job_status"
|
"asset_job_status"
|
||||||
|
|
||||||
|
-- PersonRepository.deleteAssetFace
|
||||||
|
delete from "asset_faces"
|
||||||
|
where
|
||||||
|
"asset_faces"."id" = $1
|
||||||
|
|
||||||
|
-- PersonRepository.softDeleteAssetFaces
|
||||||
|
update "asset_faces"
|
||||||
|
set
|
||||||
|
"deletedAt" = $1
|
||||||
|
where
|
||||||
|
"asset_faces"."id" = $2
|
||||||
|
@ -132,7 +132,7 @@ export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffli
|
|||||||
|
|
||||||
export interface GetByIdsRelations {
|
export interface GetByIdsRelations {
|
||||||
exifInfo?: boolean;
|
exifInfo?: boolean;
|
||||||
faces?: { person?: boolean };
|
faces?: { person?: boolean; withDeleted?: boolean };
|
||||||
files?: boolean;
|
files?: boolean;
|
||||||
library?: boolean;
|
library?: boolean;
|
||||||
owner?: boolean;
|
owner?: boolean;
|
||||||
@ -262,7 +262,11 @@ export class AssetRepository {
|
|||||||
.selectAll('assets')
|
.selectAll('assets')
|
||||||
.where('assets.id', '=', anyUuid(ids))
|
.where('assets.id', '=', anyUuid(ids))
|
||||||
.$if(!!exifInfo, withExif)
|
.$if(!!exifInfo, withExif)
|
||||||
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces))
|
.$if(!!faces, (qb) =>
|
||||||
|
qb.select((eb) =>
|
||||||
|
faces?.person ? withFacesAndPeople(eb, faces.withDeleted) : withFaces(eb, faces?.withDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
.$if(!!files, (qb) => qb.select(withFiles))
|
.$if(!!files, (qb) => qb.select(withFiles))
|
||||||
.$if(!!library, (qb) => qb.select(withLibrary))
|
.$if(!!library, (qb) => qb.select(withLibrary))
|
||||||
.$if(!!owner, (qb) => qb.select(withOwner))
|
.$if(!!owner, (qb) => qb.select(withOwner))
|
||||||
|
@ -130,6 +130,7 @@ export class PersonRepository {
|
|||||||
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
|
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
|
||||||
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
|
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
|
||||||
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.stream() as AsyncIterableIterator<AssetFaceEntity>;
|
.stream() as AsyncIterableIterator<AssetFaceEntity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,6 +162,7 @@ export class PersonRepository {
|
|||||||
.on('assets.deletedAt', 'is', null),
|
.on('assets.deletedAt', 'is', null),
|
||||||
)
|
)
|
||||||
.where('person.ownerId', '=', userId)
|
.where('person.ownerId', '=', userId)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.orderBy('person.isHidden', 'asc')
|
.orderBy('person.isHidden', 'asc')
|
||||||
.orderBy('person.isFavorite', 'desc')
|
.orderBy('person.isFavorite', 'desc')
|
||||||
.having((eb) =>
|
.having((eb) =>
|
||||||
@ -212,6 +214,7 @@ export class PersonRepository {
|
|||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.selectAll('person')
|
.selectAll('person')
|
||||||
.leftJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
.leftJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
.having((eb) => eb.fn.count('asset_faces.assetId'), '=', 0)
|
||||||
.groupBy('person.id')
|
.groupBy('person.id')
|
||||||
.execute() as Promise<PersonEntity[]>;
|
.execute() as Promise<PersonEntity[]>;
|
||||||
@ -224,6 +227,7 @@ export class PersonRepository {
|
|||||||
.selectAll('asset_faces')
|
.selectAll('asset_faces')
|
||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.where('asset_faces.assetId', '=', assetId)
|
.where('asset_faces.assetId', '=', assetId)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
.orderBy('asset_faces.boundingBoxX1', 'asc')
|
||||||
.execute() as Promise<AssetFaceEntity[]>;
|
.execute() as Promise<AssetFaceEntity[]>;
|
||||||
}
|
}
|
||||||
@ -236,6 +240,7 @@ export class PersonRepository {
|
|||||||
.selectAll('asset_faces')
|
.selectAll('asset_faces')
|
||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.where('asset_faces.id', '=', id)
|
.where('asset_faces.id', '=', id)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
|
.executeTakeFirstOrThrow() as Promise<AssetFaceEntity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,6 +258,7 @@ export class PersonRepository {
|
|||||||
.select(withAsset)
|
.select(withAsset)
|
||||||
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
|
.$if(!!relations?.faceSearch, (qb) => qb.select(withFaceSearch))
|
||||||
.where('asset_faces.id', '=', id)
|
.where('asset_faces.id', '=', id)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,6 +323,7 @@ export class PersonRepository {
|
|||||||
.on('assets.deletedAt', 'is', null),
|
.on('assets.deletedAt', 'is', null),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
|
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -330,6 +337,7 @@ export class PersonRepository {
|
|||||||
.selectFrom('person')
|
.selectFrom('person')
|
||||||
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
.innerJoin('asset_faces', 'asset_faces.personId', 'person.id')
|
||||||
.where('person.ownerId', '=', userId)
|
.where('person.ownerId', '=', userId)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.innerJoin('assets', (join) =>
|
.innerJoin('assets', (join) =>
|
||||||
join
|
join
|
||||||
.onRef('assets.id', '=', 'asset_faces.assetId')
|
.onRef('assets.id', '=', 'asset_faces.assetId')
|
||||||
@ -434,6 +442,7 @@ export class PersonRepository {
|
|||||||
.select(withPerson)
|
.select(withPerson)
|
||||||
.where('asset_faces.assetId', 'in', assetIds)
|
.where('asset_faces.assetId', 'in', assetIds)
|
||||||
.where('asset_faces.personId', 'in', personIds)
|
.where('asset_faces.personId', 'in', personIds)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.execute() as Promise<AssetFaceEntity[]>;
|
.execute() as Promise<AssetFaceEntity[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -443,6 +452,7 @@ export class PersonRepository {
|
|||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
.selectAll('asset_faces')
|
.selectAll('asset_faces')
|
||||||
.where('asset_faces.personId', '=', personId)
|
.where('asset_faces.personId', '=', personId)
|
||||||
|
.where('asset_faces.deletedAt', 'is', null)
|
||||||
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
.executeTakeFirst() as Promise<AssetFaceEntity | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,6 +466,20 @@ export class PersonRepository {
|
|||||||
return result?.latestDate;
|
return result?.latestDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAssetFace(face: Insertable<AssetFaces>): Promise<void> {
|
||||||
|
await this.db.insertInto('asset_faces').values(face).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
async deleteAssetFace(id: string): Promise<void> {
|
||||||
|
await this.db.deleteFrom('asset_faces').where('asset_faces.id', '=', id).execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
async softDeleteAssetFaces(id: string): Promise<void> {
|
||||||
|
await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute();
|
||||||
|
}
|
||||||
|
|
||||||
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
|
private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise<void> {
|
||||||
await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
|
await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db);
|
||||||
await sql`REINDEX TABLE asset_faces`.execute(this.db);
|
await sql`REINDEX TABLE asset_faces`.execute(this.db);
|
||||||
|
@ -5,9 +5,13 @@ import { OnJob } from 'src/decorators';
|
|||||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
|
AssetFaceCreateDto,
|
||||||
|
AssetFaceDeleteDto,
|
||||||
AssetFaceResponseDto,
|
AssetFaceResponseDto,
|
||||||
AssetFaceUpdateDto,
|
AssetFaceUpdateDto,
|
||||||
FaceDto,
|
FaceDto,
|
||||||
|
mapFaces,
|
||||||
|
mapPerson,
|
||||||
MergePersonDto,
|
MergePersonDto,
|
||||||
PeopleResponseDto,
|
PeopleResponseDto,
|
||||||
PeopleUpdateDto,
|
PeopleUpdateDto,
|
||||||
@ -16,8 +20,6 @@ import {
|
|||||||
PersonSearchDto,
|
PersonSearchDto,
|
||||||
PersonStatisticsResponseDto,
|
PersonStatisticsResponseDto,
|
||||||
PersonUpdateDto,
|
PersonUpdateDto,
|
||||||
mapFaces,
|
|
||||||
mapPerson,
|
|
||||||
} from 'src/dtos/person.dto';
|
} from 'src/dtos/person.dto';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
@ -295,7 +297,7 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relations = { exifInfo: true, faces: { person: false }, files: true };
|
const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true };
|
||||||
const [asset] = await this.assetRepository.getByIds([id], relations);
|
const [asset] = await this.assetRepository.getByIds([id], relations);
|
||||||
const { previewFile } = getAssetFiles(asset.files);
|
const { previewFile } = getAssetFiles(asset.files);
|
||||||
if (!asset || !previewFile) {
|
if (!asset || !previewFile) {
|
||||||
@ -717,4 +719,29 @@ export class PersonService extends BaseService {
|
|||||||
height: newHalfSize * 2,
|
height: newHalfSize * 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO return a asset face response
|
||||||
|
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.assetId] }),
|
||||||
|
this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [dto.personId] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.personRepository.createAssetFace({
|
||||||
|
personId: dto.personId,
|
||||||
|
assetId: dto.assetId,
|
||||||
|
imageHeight: dto.imageHeight,
|
||||||
|
imageWidth: dto.imageWidth,
|
||||||
|
boundingBoxX1: dto.x,
|
||||||
|
boundingBoxX2: dto.x + dto.width,
|
||||||
|
boundingBoxY1: dto.y,
|
||||||
|
boundingBoxY2: dto.y + dto.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise<void> {
|
||||||
|
await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] });
|
||||||
|
|
||||||
|
return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -217,6 +217,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
|||||||
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
|
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case Permission.FACE_DELETE: {
|
||||||
|
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
case Permission.TAG_ASSET:
|
case Permission.TAG_ASSET:
|
||||||
case Permission.TAG_READ:
|
case Permission.TAG_READ:
|
||||||
case Permission.TAG_UPDATE:
|
case Permission.TAG_UPDATE:
|
||||||
|
20
server/test/fixtures/face.stub.ts
vendored
20
server/test/fixtures/face.stub.ts
vendored
@ -20,8 +20,9 @@ export const faceStub = {
|
|||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: new Date(),
|
||||||
}),
|
}),
|
||||||
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
primaryFace1: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId2',
|
id: 'assetFaceId2',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -35,8 +36,9 @@ export const faceStub = {
|
|||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
mergeFace1: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId3',
|
id: 'assetFaceId3',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -50,8 +52,9 @@ export const faceStub = {
|
|||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
start: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId5',
|
id: 'assetFaceId5',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -65,8 +68,9 @@ export const faceStub = {
|
|||||||
imageWidth: 2160,
|
imageWidth: 2160,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId5', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
middle: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId6',
|
id: 'assetFaceId6',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -80,8 +84,9 @@ export const faceStub = {
|
|||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId6', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
|
end: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId7',
|
id: 'assetFaceId7',
|
||||||
assetId: assetStub.image.id,
|
assetId: assetStub.image.id,
|
||||||
asset: assetStub.image,
|
asset: assetStub.image,
|
||||||
@ -95,6 +100,7 @@ export const faceStub = {
|
|||||||
imageWidth: 500,
|
imageWidth: 500,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId7', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
noPerson1: Object.freeze<AssetFaceEntity>({
|
noPerson1: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId8',
|
id: 'assetFaceId8',
|
||||||
@ -110,6 +116,7 @@ export const faceStub = {
|
|||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
noPerson2: Object.freeze<AssetFaceEntity>({
|
noPerson2: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
@ -125,6 +132,7 @@ export const faceStub = {
|
|||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
sourceType: SourceType.MACHINE_LEARNING,
|
sourceType: SourceType.MACHINE_LEARNING,
|
||||||
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
|
faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' },
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
fromExif1: Object.freeze<AssetFaceEntity>({
|
fromExif1: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
@ -139,6 +147,7 @@ export const faceStub = {
|
|||||||
imageHeight: 500,
|
imageHeight: 500,
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
sourceType: SourceType.EXIF,
|
sourceType: SourceType.EXIF,
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
fromExif2: Object.freeze<AssetFaceEntity>({
|
fromExif2: Object.freeze<AssetFaceEntity>({
|
||||||
id: 'assetFaceId9',
|
id: 'assetFaceId9',
|
||||||
@ -153,5 +162,6 @@ export const faceStub = {
|
|||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
sourceType: SourceType.EXIF,
|
sourceType: SourceType.EXIF,
|
||||||
|
deletedAt: null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -33,5 +33,9 @@ export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepo
|
|||||||
getFaceByIdWithAssets: vitest.fn(),
|
getFaceByIdWithAssets: vitest.fn(),
|
||||||
getNumberOfPeople: vitest.fn(),
|
getNumberOfPeople: vitest.fn(),
|
||||||
getLatestFaceDate: vitest.fn(),
|
getLatestFaceDate: vitest.fn(),
|
||||||
|
|
||||||
|
createAssetFace: vitest.fn(),
|
||||||
|
deleteAssetFace: vitest.fn(),
|
||||||
|
softDeleteAssetFaces: vitest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
989
web/package-lock.json
generated
989
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -78,6 +78,7 @@
|
|||||||
"@photo-sphere-viewer/video-plugin": "^5.11.5",
|
"@photo-sphere-viewer/video-plugin": "^5.11.5",
|
||||||
"@zoom-image/svelte": "^0.3.0",
|
"@zoom-image/svelte": "^0.3.0",
|
||||||
"dom-to-image": "^2.6.0",
|
"dom-to-image": "^2.6.0",
|
||||||
|
"fabric": "^6.5.4",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"intl-messageformat": "^10.7.11",
|
"intl-messageformat": "^10.7.11",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
@ -25,7 +25,6 @@
|
|||||||
type ExifResponseDto,
|
type ExifResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
mdiAccountOff,
|
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
mdiCameraIris,
|
mdiCameraIris,
|
||||||
mdiClose,
|
mdiClose,
|
||||||
@ -34,6 +33,7 @@
|
|||||||
mdiImageOutline,
|
mdiImageOutline,
|
||||||
mdiInformationOutline,
|
mdiInformationOutline,
|
||||||
mdiPencil,
|
mdiPencil,
|
||||||
|
mdiPlus,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -46,6 +46,7 @@
|
|||||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@ -186,20 +187,11 @@
|
|||||||
<DetailPanelDescription {asset} {isOwner} />
|
<DetailPanelDescription {asset} {isOwner} />
|
||||||
<DetailPanelRating {asset} {isOwner} />
|
<DetailPanelRating {asset} {isOwner} />
|
||||||
|
|
||||||
{#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
|
{#if !isSharedLink() && isOwner}
|
||||||
<section class="px-4 pt-4 text-sm">
|
<section class="px-4 pt-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>{$t('people').toUpperCase()}</h2>
|
<h2>{$t('people').toUpperCase()}</h2>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{#if unassignedFaces.length > 0}
|
|
||||||
<Icon
|
|
||||||
ariaLabel={$t('asset_has_unassigned_faces')}
|
|
||||||
title={$t('asset_has_unassigned_faces')}
|
|
||||||
color="currentColor"
|
|
||||||
path={mdiAccountOff}
|
|
||||||
size="24"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if people.some((person) => person.isHidden)}
|
{#if people.some((person) => person.isHidden)}
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
title={$t('show_hidden_people')}
|
title={$t('show_hidden_people')}
|
||||||
@ -210,13 +202,24 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
title={$t('edit_people')}
|
title={$t('tag_people')}
|
||||||
icon={mdiPencil}
|
icon={mdiPlus}
|
||||||
padding="1"
|
padding="1"
|
||||||
size="20"
|
size="20"
|
||||||
buttonSize="32"
|
buttonSize="32"
|
||||||
onclick={() => (showEditFaces = true)}
|
onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||||
|
<CircleIconButton
|
||||||
|
title={$t('edit_people')}
|
||||||
|
icon={mdiPencil}
|
||||||
|
padding="1"
|
||||||
|
size="20"
|
||||||
|
buttonSize="32"
|
||||||
|
onclick={() => (showEditFaces = true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
|
import { notificationController } from '$lib/components/shared-components/notification/notification';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
|
||||||
|
import { Button } from '@immich/ui';
|
||||||
|
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imgElement: HTMLImageElement;
|
||||||
|
containerWidth: number;
|
||||||
|
containerHeight: number;
|
||||||
|
assetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { imgElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||||
|
|
||||||
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
|
let canvas: Canvas | undefined = $state();
|
||||||
|
let faceRect: Rect | undefined = $state();
|
||||||
|
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
const configureControlStyle = () => {
|
||||||
|
InteractiveFabricObject.ownDefaults = {
|
||||||
|
...InteractiveFabricObject.ownDefaults,
|
||||||
|
cornerStyle: 'circle',
|
||||||
|
cornerColor: 'rgb(153,166,251)',
|
||||||
|
cornerSize: 10,
|
||||||
|
padding: 8,
|
||||||
|
transparentCorners: false,
|
||||||
|
lockRotation: true,
|
||||||
|
hasBorders: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCanvas = () => {
|
||||||
|
if (!canvasEl || !imgElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas = new Canvas(canvasEl);
|
||||||
|
configureControlStyle();
|
||||||
|
|
||||||
|
faceRect = new Rect({
|
||||||
|
fill: 'rgba(66,80,175,0.25)',
|
||||||
|
stroke: 'rgb(66,80,175)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeUniform: true,
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
objectCaching: true,
|
||||||
|
rx: 8,
|
||||||
|
ry: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(faceRect);
|
||||||
|
canvas.setActiveObject(faceRect);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
setupCanvas();
|
||||||
|
await getPeople();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { actualWidth, actualHeight } = getContainedSize(imgElement);
|
||||||
|
const offsetArea = {
|
||||||
|
width: (containerWidth - actualWidth) / 2,
|
||||||
|
height: (containerHeight - actualHeight) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageBoundingBox = {
|
||||||
|
top: offsetArea.height,
|
||||||
|
left: offsetArea.width,
|
||||||
|
width: containerWidth - offsetArea.width * 2,
|
||||||
|
height: containerHeight - offsetArea.height * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.setDimensions({
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!faceRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
faceRect.set({
|
||||||
|
top: imageBoundingBox.top + 200,
|
||||||
|
left: imageBoundingBox.left + 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
faceRect.setCoords();
|
||||||
|
positionFaceSelector();
|
||||||
|
});
|
||||||
|
|
||||||
|
const getContainedSize = (img: HTMLImageElement): { actualWidth: number; actualHeight: number } => {
|
||||||
|
const ratio = img.naturalWidth / img.naturalHeight;
|
||||||
|
let actualWidth = img.height * ratio;
|
||||||
|
let actualHeight = img.height;
|
||||||
|
if (actualWidth > img.width) {
|
||||||
|
actualWidth = img.width;
|
||||||
|
actualHeight = img.width / ratio;
|
||||||
|
}
|
||||||
|
return { actualWidth, actualHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = $state(1);
|
||||||
|
let candidates = $state<PersonResponseDto[]>([]);
|
||||||
|
|
||||||
|
const getPeople = async () => {
|
||||||
|
const { hasNextPage, people, total } = await getAllPeople({ page, size: 250, withHidden: false });
|
||||||
|
|
||||||
|
if (candidates.length === total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates = [...candidates, ...people];
|
||||||
|
|
||||||
|
if (hasNextPage) {
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionFaceSelector = () => {
|
||||||
|
if (!faceRect || !faceSelectorEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = faceRect.getBoundingRect();
|
||||||
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
|
const selectorHeight = faceSelectorEl.offsetHeight;
|
||||||
|
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const spaceBelow = containerHeight - (rect.top + rect.height);
|
||||||
|
const spaceLeft = rect.left;
|
||||||
|
const spaceRight = containerWidth - (rect.left + rect.width);
|
||||||
|
|
||||||
|
let top, left;
|
||||||
|
|
||||||
|
if (
|
||||||
|
spaceBelow >= selectorHeight ||
|
||||||
|
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
||||||
|
) {
|
||||||
|
top = rect.top + rect.height + 15;
|
||||||
|
left = rect.left;
|
||||||
|
} else if (
|
||||||
|
spaceAbove >= selectorHeight ||
|
||||||
|
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
||||||
|
) {
|
||||||
|
top = rect.top - selectorHeight - 15;
|
||||||
|
left = rect.left;
|
||||||
|
} else if (
|
||||||
|
spaceRight >= selectorWidth ||
|
||||||
|
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
||||||
|
) {
|
||||||
|
top = rect.top;
|
||||||
|
left = rect.left + rect.width + 15;
|
||||||
|
} else {
|
||||||
|
top = rect.top;
|
||||||
|
left = rect.left - selectorWidth - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left + selectorWidth > containerWidth) {
|
||||||
|
left = containerWidth - selectorWidth - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left < 0) {
|
||||||
|
left = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + selectorHeight > containerHeight) {
|
||||||
|
top = containerHeight - selectorHeight - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top < 0) {
|
||||||
|
top = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
faceSelectorEl.style.top = `${top}px`;
|
||||||
|
faceSelectorEl.style.left = `${left}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (faceRect) {
|
||||||
|
faceRect.on('moving', positionFaceSelector);
|
||||||
|
faceRect.on('scaling', positionFaceSelector);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getFaceCroppedCoordinates = () => {
|
||||||
|
if (!faceRect || !imgElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||||
|
const { actualWidth, actualHeight } = getContainedSize(imgElement);
|
||||||
|
|
||||||
|
const offsetArea = {
|
||||||
|
width: (containerWidth - actualWidth) / 2,
|
||||||
|
height: (containerHeight - actualHeight) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const x1Coeff = (left - offsetArea.width) / actualWidth;
|
||||||
|
const y1Coeff = (top - offsetArea.height) / actualHeight;
|
||||||
|
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
|
||||||
|
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
|
||||||
|
|
||||||
|
// transpose to the natural image location
|
||||||
|
const x1 = x1Coeff * imgElement.naturalWidth;
|
||||||
|
const y1 = y1Coeff * imgElement.naturalHeight;
|
||||||
|
const x2 = x2Coeff * imgElement.naturalWidth;
|
||||||
|
const y2 = y2Coeff * imgElement.naturalHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageWidth: imgElement.naturalWidth,
|
||||||
|
imageHeight: imgElement.naturalHeight,
|
||||||
|
x: Math.floor(x1),
|
||||||
|
y: Math.floor(y1),
|
||||||
|
width: Math.floor(x2 - x1),
|
||||||
|
height: Math.floor(y2 - y1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagFace = async (person: PersonResponseDto) => {
|
||||||
|
try {
|
||||||
|
const data = getFaceCroppedCoordinates();
|
||||||
|
if (!data) {
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Error tagging face - cannot get bounding box coordinates',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfirmed = await dialogController.show({
|
||||||
|
prompt: `Do you want to tag this face as ${person.name}?`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFace({
|
||||||
|
assetFaceCreateDto: {
|
||||||
|
assetId,
|
||||||
|
personId: person.id,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assetViewingStore.setAssetId(assetId);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Error tagging face');
|
||||||
|
} finally {
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="absolute left-0 top-0">
|
||||||
|
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 left-0"></canvas>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="face-selector"
|
||||||
|
bind:this={faceSelectorEl}
|
||||||
|
class="absolute top-[calc(50%-250px)] left-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200"
|
||||||
|
>
|
||||||
|
<p class="text-center text-sm">Select a person to tag</p>
|
||||||
|
|
||||||
|
<div class="max-h-[250px] overflow-y-auto mt-2">
|
||||||
|
<div class="mt-2 rounded-lg">
|
||||||
|
{#each candidates as person}
|
||||||
|
<button
|
||||||
|
onclick={() => tagFace(person)}
|
||||||
|
type="button"
|
||||||
|
class="w-full flex place-items-center gap-2 rounded-lg pl-1 pr-4 py-2 hover:bg-immich-primary/25"
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(person)}
|
||||||
|
altText={person.name}
|
||||||
|
title={person.name}
|
||||||
|
widthStyle="30px"
|
||||||
|
heightStyle="30px"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
{person.name}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -7,6 +7,14 @@ import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
|||||||
import { render } from '@testing-library/svelte';
|
import { render } from '@testing-library/svelte';
|
||||||
import type { MockInstance } from 'vitest';
|
import type { MockInstance } from 'vitest';
|
||||||
|
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = ResizeObserver;
|
||||||
|
|
||||||
vi.mock('$lib/utils', async (originalImport) => {
|
vi.mock('$lib/utils', async (originalImport) => {
|
||||||
const meta = await originalImport<typeof import('$lib/utils')>();
|
const meta = await originalImport<typeof import('$lib/utils')>();
|
||||||
return {
|
return {
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import { photoViewer } from '$lib/stores/assets.store';
|
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
@ -19,6 +18,9 @@
|
|||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
|
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@ -91,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyImageToClipboard($photoViewer ?? assetFileUrl);
|
await copyImageToClipboard($photoViewerImgElement ?? assetFileUrl);
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
message: $t('copied_image_to_clipboard'),
|
message: $t('copied_image_to_clipboard'),
|
||||||
@ -106,6 +108,12 @@
|
|||||||
$zoomed = $zoomed ? false : true;
|
$zoomed = $zoomed ? false : true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
|
||||||
|
zoomToggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||||
if (globalThis.getSelection()?.type === 'Range') {
|
if (globalThis.getSelection()?.type === 'Range') {
|
||||||
return;
|
return;
|
||||||
@ -159,6 +167,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
|
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
|
||||||
|
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
let containerHeight = $state(0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@ -172,7 +183,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
<!-- svelte-ignore a11y_missing_attribute -->
|
||||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||||
<div bind:this={element} class="relative h-full select-none">
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
class="relative h-full select-none"
|
||||||
|
bind:clientWidth={containerWidth}
|
||||||
|
bind:clientHeight={containerHeight}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
style="display:none"
|
style="display:none"
|
||||||
src={imageLoaderUrl}
|
src={imageLoaderUrl}
|
||||||
@ -201,7 +217,7 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<img
|
<img
|
||||||
bind:this={$photoViewer}
|
bind:this={$photoViewerImgElement}
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt={$getAltText(asset)}
|
alt={$getAltText(asset)}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
@ -209,13 +225,17 @@
|
|||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
: slideshowLookCssMapping[$slideshowLook]}"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
|
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
||||||
<div
|
<div
|
||||||
class="absolute border-solid border-white border-[3px] rounded-lg"
|
class="absolute border-solid border-white border-[3px] rounded-lg"
|
||||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
></div>
|
></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isFaceEditMode.value}
|
||||||
|
<FaceEditor imgElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg',
|
transparent: 'bg-transparent hover:bg-[#d3d3d3] dark:text-immich-dark-fg',
|
||||||
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
|
opaque: 'bg-transparent hover:bg-immich-bg/30 text-white hover:dark:text-white',
|
||||||
light: 'bg-white hover:bg-[#d3d3d3]',
|
light: 'bg-white hover:bg-[#d3d3d3]',
|
||||||
red: 'text-red-400 hover:bg-[#d3d3d3]',
|
red: 'text-red-400 bg-red-100 hover:bg-[#d3d3d3]',
|
||||||
dark: 'bg-[#202123] hover:bg-[#d3d3d3]',
|
dark: 'bg-[#202123] hover:bg-[#d3d3d3]',
|
||||||
alert: 'text-[#ff0000] hover:text-white',
|
alert: 'text-[#ff0000] hover:text-white',
|
||||||
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',
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||||
import { linear } from 'svelte/easing';
|
import { linear } from 'svelte/easing';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { photoViewer } from '$lib/stores/assets.store';
|
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.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';
|
||||||
@ -62,7 +62,7 @@
|
|||||||
const handleCreatePerson = async () => {
|
const handleCreatePerson = async () => {
|
||||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||||
|
|
||||||
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewer);
|
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement);
|
||||||
|
|
||||||
onCreatePerson(newFeaturePhoto);
|
onCreatePerson(newFeaturePhoto);
|
||||||
|
|
||||||
|
@ -13,9 +13,10 @@
|
|||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
type AssetFaceResponseDto,
|
type AssetFaceResponseDto,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
|
deleteFace,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart } from '@mdi/js';
|
import { mdiAccountOff, mdiArrowLeftThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
|
||||||
import { 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';
|
||||||
@ -24,8 +25,10 @@
|
|||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
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 { zoomImageToBase64 } from '$lib/utils/people-utils';
|
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||||
import { photoViewer } from '$lib/stores/assets.store';
|
import { photoViewerImgElement } from '$lib/stores/assets.store';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@ -163,6 +166,30 @@
|
|||||||
editedFace = face;
|
editedFace = face;
|
||||||
showSelectedFaces = true;
|
showSelectedFaces = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteAssetFace = async (face: AssetFaceResponseDto) => {
|
||||||
|
try {
|
||||||
|
if (!face.person) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfirmed = await dialogController.show({
|
||||||
|
prompt: $t('confirm_delete_face', { values: { name: face.person.name } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } });
|
||||||
|
|
||||||
|
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||||
|
|
||||||
|
await assetViewingStore.setAssetId(assetId);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('error_delete_face'));
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@ -242,7 +269,7 @@
|
|||||||
hidden={face.person.isHidden}
|
hidden={face.person.isHidden}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#await zoomImageToBase64(face, assetId, assetType, $photoViewer)}
|
{#await zoomImageToBase64(face, assetId, assetType, $photoViewerImgElement)}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
curve
|
curve
|
||||||
shadow
|
shadow
|
||||||
@ -308,6 +335,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if face.person != null}
|
||||||
|
<div class="absolute -right-[5px] top-[25px] h-[20px] w-[20px] rounded-full">
|
||||||
|
<CircleIconButton
|
||||||
|
color="red"
|
||||||
|
icon={mdiTrashCan}
|
||||||
|
title={$t('delete_face')}
|
||||||
|
size="18"
|
||||||
|
padding="1"
|
||||||
|
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
||||||
|
onclick={() => deleteAssetFace(face)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
@ -148,7 +148,7 @@ interface UpdateStackAssets {
|
|||||||
values: string[];
|
values: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const photoViewer = writable<HTMLImageElement | null>(null);
|
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
type PendingChange = AddAsset | UpdateAsset | DeleteAsset | TrashAssets | UpdateStackAssets;
|
||||||
|
|
||||||
|
1
web/src/lib/stores/face-edit.svelte.ts
Normal file
1
web/src/lib/stores/face-edit.svelte.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const isFaceEditMode = $state({ value: false });
|
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { beforeNavigate } from '$app/navigation';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||||
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
|
||||||
@ -22,6 +23,7 @@
|
|||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets.store';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
@ -68,6 +70,10 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
assetStore.destroy();
|
assetStore.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeNavigate(() => {
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
|
Reference in New Issue
Block a user