Compare commits

..

2 Commits

Author SHA1 Message Date
mertalev
f48318168b add extension to tmp path 2024-05-27 18:47:36 -04:00
mertalev
dda736840b save to tmp path then rename 2024-05-27 18:33:58 -04:00
40 changed files with 990 additions and 1993 deletions

View File

@@ -215,18 +215,15 @@ describe('/asset', () => {
expect(body).toMatchObject({
id: user1Assets[0].id,
isFavorite: false,
people: {
visiblePeople: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '/my/awesome/thumbnail.jpg',
},
],
numberOfFaces: 1,
},
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '/my/awesome/thumbnail.jpg',
},
],
});
const sharedLink = await utils.createSharedLink(user1.accessToken, {
@@ -236,7 +233,7 @@ describe('/asset', () => {
const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`);
expect(data.status).toBe(200);
expect(data.body).not.toHaveProperty('people');
expect(data.body).toMatchObject({ people: [] });
});
describe('partner assets', () => {
@@ -514,18 +511,15 @@ describe('/asset', () => {
expect(body).toMatchObject({
id: user1Assets[0].id,
isFavorite: true,
people: {
visiblePeople: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '/my/awesome/thumbnail.jpg',
},
],
numberOfFaces: 1,
},
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '/my/awesome/thumbnail.jpg',
},
],
});
});
});

View File

@@ -84,7 +84,7 @@ class AssetService {
final AssetResponseDto? dto =
await _apiService.assetApi.getAssetInfo(remoteId);
return dto?.people?.visiblePeople;
return dto?.people;
} catch (error, stack) {
log.severe(
'Error while getting remote asset info: ${error.toString()}',

View File

@@ -122,7 +122,6 @@ Class | Method | HTTP request | Description
*DuplicateApi* | [**getAssetDuplicates**](doc//DuplicateApi.md#getassetduplicates) | **GET** /duplicates |
*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /faces |
*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* | [**getAuditFiles**](doc//FileReportApi.md#getauditfiles) | **GET** /reports |
*FileReportApi* | [**getFileChecksums**](doc//FileReportApi.md#getfilechecksums) | **POST** /reports/checksum |
@@ -161,7 +160,6 @@ Class | Method | HTTP request | Description
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail |
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /people/{id}/merge |
*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* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /people/{id} |
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities |
@@ -331,7 +329,6 @@ Class | Method | HTTP request | Description
- [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PeopleUpdateDto](doc//PeopleUpdateDto.md)
- [PeopleUpdateItem](doc//PeopleUpdateItem.md)
- [PeopleWithFacesResponseDto](doc//PeopleWithFacesResponseDto.md)
- [PersonCreateDto](doc//PersonCreateDto.md)
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)

View File

@@ -1,16 +0,0 @@
# 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)

View File

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

View File

@@ -119,52 +119,4 @@ class FaceApi {
}
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;
}
}

View File

@@ -419,56 +419,6 @@ class PersonApi {
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].
/// Parameters:
///

View File

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

View File

@@ -34,7 +34,7 @@ class AssetResponseDto {
required this.originalPath,
this.owner,
required this.ownerId,
this.people,
this.people = const [],
required this.resized,
this.smartInfo,
this.stack = const [],
@@ -102,13 +102,7 @@ class AssetResponseDto {
String ownerId;
///
/// 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;
List<PersonWithFacesResponseDto> people;
bool resized;
@@ -157,7 +151,7 @@ class AssetResponseDto {
other.originalPath == originalPath &&
other.owner == owner &&
other.ownerId == ownerId &&
other.people == people &&
_deepEquality.equals(other.people, people) &&
other.resized == resized &&
other.smartInfo == smartInfo &&
_deepEquality.equals(other.stack, stack) &&
@@ -192,7 +186,7 @@ class AssetResponseDto {
(originalPath.hashCode) +
(owner == null ? 0 : owner!.hashCode) +
(ownerId.hashCode) +
(people == null ? 0 : people!.hashCode) +
(people.hashCode) +
(resized.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode) +
(stack.hashCode) +
@@ -249,11 +243,7 @@ class AssetResponseDto {
// json[r'owner'] = null;
}
json[r'ownerId'] = this.ownerId;
if (this.people != null) {
json[r'people'] = this.people;
} else {
// json[r'people'] = null;
}
json[r'resized'] = this.resized;
if (this.smartInfo != null) {
json[r'smartInfo'] = this.smartInfo;
@@ -311,7 +301,7 @@ class AssetResponseDto {
originalPath: mapValueOfType<String>(json, r'originalPath')!,
owner: UserResponseDto.fromJson(json[r'owner']),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: PeopleWithFacesResponseDto.fromJson(json[r'people']),
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType<bool>(json, r'resized')!,
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
stack: AssetResponseDto.listFromJson(json[r'stack']),

View File

@@ -1,106 +0,0 @@
//
// 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',
};
}

View File

@@ -1,32 +0,0 @@
//
// 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
});
});
}

View File

@@ -2531,46 +2531,6 @@
}
},
"/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": {
"operationId": "reassignFacesById",
"parameters": [
@@ -3711,38 +3671,6 @@
}
},
"/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": {
"operationId": "getAllPeople",
"parameters": [
@@ -7571,7 +7499,10 @@
"type": "string"
},
"people": {
"$ref": "#/components/schemas/PeopleWithFacesResponseDto"
"items": {
"$ref": "#/components/schemas/PersonWithFacesResponseDto"
},
"type": "array"
},
"resized": {
"type": "boolean"
@@ -9064,24 +8995,6 @@
],
"type": "object"
},
"PeopleWithFacesResponseDto": {
"properties": {
"numberOfFaces": {
"type": "integer"
},
"visiblePeople": {
"items": {
"$ref": "#/components/schemas/PersonWithFacesResponseDto"
},
"type": "array"
}
},
"required": [
"numberOfFaces",
"visiblePeople"
],
"type": "object"
},
"PersonCreateDto": {
"properties": {
"birthDate": {

View File

@@ -123,10 +123,6 @@ export type PersonWithFacesResponseDto = {
name: string;
thumbnailPath: string;
};
export type PeopleWithFacesResponseDto = {
numberOfFaces: number;
visiblePeople: PersonWithFacesResponseDto[];
};
export type SmartInfoResponseDto = {
objects?: string[] | null;
tags?: string[] | null;
@@ -161,7 +157,7 @@ export type AssetResponseDto = {
originalPath: string;
owner?: UserResponseDto;
ownerId: string;
people?: PeopleWithFacesResponseDto;
people?: PersonWithFacesResponseDto[];
resized: boolean;
smartInfo?: SmartInfoResponseDto;
stack?: AssetResponseDto[];
@@ -553,13 +549,6 @@ export type PartnerResponseDto = {
export type UpdatePartnerDto = {
inTimeline: boolean;
};
export type AssetFaceUpdateItem = {
assetId: string;
personId: string;
};
export type AssetFaceUpdateDto = {
data: AssetFaceUpdateItem[];
};
export type PeopleResponseDto = {
hidden: number;
people: PersonResponseDto[];
@@ -604,6 +593,13 @@ export type PersonUpdateDto = {
export type MergePersonDto = {
ids: string[];
};
export type AssetFaceUpdateItem = {
assetId: string;
personId: string;
};
export type AssetFaceUpdateDto = {
data: AssetFaceUpdateItem[];
};
export type PersonStatisticsResponseDto = {
assets: number;
};
@@ -1771,17 +1767,6 @@ export function getFaces({ id }: {
...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 }: {
id: string;
faceDto: FaceDto;
@@ -2079,18 +2064,6 @@ export function updatePartner({ id, 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 }: {
withHidden?: boolean;
}, opts?: Oazapfts.RequestOpts) {

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
@@ -26,10 +26,4 @@ export class FaceController {
): Promise<PersonResponseDto> {
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);
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Inject, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Inject, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -87,11 +87,6 @@ export class PersonController {
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')
@Authenticated()
reassignFaces(

View File

@@ -2,12 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
import {
PeopleWithFacesResponseDto,
PersonWithFacesResponseDto,
mapFacesWithoutPerson,
mapPerson,
} from 'src/dtos/person.dto';
import { PersonWithFacesResponseDto, mapFacesWithoutPerson, mapPerson } from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@@ -45,7 +40,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
tags?: TagResponseDto[];
people?: PeopleWithFacesResponseDto;
people?: PersonWithFacesResponseDto[];
/**base64 encoded sha1 hash */
checksum!: string;
stackParentId?: string | null;
@@ -61,7 +56,7 @@ export type AssetMapOptions = {
auth?: AuthDto;
};
const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto => {
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
const result: PersonWithFacesResponseDto[] = [];
if (faces) {
for (const face of faces) {
@@ -76,7 +71,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto =
}
}
return { visiblePeople: result, numberOfFaces: faces.length };
return result;
};
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
@@ -120,7 +115,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
people: entity.faces ? peopleWithFaces(entity.faces) : undefined,
people: peopleWithFaces(entity.faces),
checksum: entity.checksum.toString('base64'),
stackParentId: withStack ? entity.stack?.primaryAssetId : undefined,
stack: withStack

View File

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

View File

@@ -37,9 +37,6 @@ export class AssetFaceEntity {
@Column({ default: 0, type: 'int' })
boundingBoxY2!: number;
@Column({ default: false })
isEdited!: boolean;
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset!: AssetEntity;

View File

@@ -23,7 +23,7 @@ export interface AssetFaceId {
export interface UpdateFacesData {
oldPersonId?: string;
faceIds?: string[];
newPersonId: string | null;
newPersonId: string;
}
export interface PersonStatistics {
@@ -60,7 +60,7 @@ export interface IPersonRepository {
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getStatistics(personId: string): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string | null): Promise<number>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;

View File

@@ -1,13 +0,0 @@
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"`);
}
}

View File

@@ -195,7 +195,6 @@ SELECT
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
"AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",

View File

@@ -71,7 +71,6 @@ SELECT
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
@@ -104,7 +103,6 @@ FROM
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
@@ -140,7 +138,6 @@ FROM
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id",
"AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt",
"AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt",
@@ -197,10 +194,9 @@ LIMIT
-- PersonRepository.reassignFace
UPDATE "asset_faces"
SET
"personId" = $1,
"isEdited" = $2
"personId" = $1
WHERE
"id" = $3
"id" = $2
-- PersonRepository.getByName
SELECT
@@ -287,7 +283,6 @@ FROM
"AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1",
"AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2",
"AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2",
"AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt",
@@ -380,7 +375,6 @@ SELECT
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited",
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
@@ -433,8 +427,7 @@ SELECT
"AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1",
"AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1",
"AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2",
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2",
"AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited"
"AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2"
FROM
"asset_faces" "AssetFaceEntity"
WHERE

View File

@@ -241,7 +241,6 @@ WITH
"faces"."boundingBoxY1" AS "boundingBoxY1",
"faces"."boundingBoxX2" AS "boundingBoxX2",
"faces"."boundingBoxY2" AS "boundingBoxY2",
"faces"."isEdited" AS "isEdited",
"faces"."embedding" <= > $1 AS "distance"
FROM
"asset_faces" "faces"

View File

@@ -148,7 +148,7 @@ export class PersonRepository implements IPersonRepository {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId, isEdited: true })
.set({ personId: newPersonId })
.where({ id: assetFaceId })
.execute();

View File

@@ -266,7 +266,7 @@ export class AssetService {
}
if (data.ownerId !== auth.user.id || auth.sharedLink) {
delete data.people;
data.people = [];
}
return data;

File diff suppressed because it is too large Load Diff

View File

@@ -188,6 +188,7 @@ export class MediaService {
const { image, ffmpeg } = await this.configCore.getConfig();
const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize;
const path = StorageCore.getImagePath(asset, type, format);
const tmpPath = `${StorageCore.getTempPathInDir(dirname(path))}.${format}`;
this.storageCore.ensureFolders(path);
switch (asset.type) {
@@ -201,8 +202,11 @@ export class MediaService {
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = { format, size, colorspace, quality: image.quality };
const outputPath = useExtracted ? extractedPath : asset.originalPath;
await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions);
const inputPath = useExtracted ? extractedPath : asset.originalPath;
await this.mediaRepository.generateThumbnail(inputPath, tmpPath, imageOptions);
} catch (error) {
await this.storageRepository.unlink(tmpPath);
throw error;
} finally {
if (didExtract) {
await this.storageRepository.unlink(extractedPath);
@@ -221,7 +225,12 @@ export class MediaService {
const mainAudioStream = this.getMainStream(audioStreams);
const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() });
const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options);
try {
await this.mediaRepository.transcode(asset.originalPath, tmpPath, options);
} catch (error) {
await this.storageRepository.unlink(tmpPath);
throw error;
}
break;
}
@@ -229,6 +238,9 @@ export class MediaService {
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
}
}
await this.storageRepository.rename(tmpPath, path);
this.logger.log(
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
);
@@ -340,8 +352,9 @@ export class MediaService {
}
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`);
const tmpPath = StorageCore.getTempPathInDir(dirname(output));
try {
await this.mediaRepository.transcode(input, output, command);
await this.mediaRepository.transcode(input, tmpPath, command);
} catch (error) {
this.logger.error(error);
if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) {
@@ -349,11 +362,20 @@ export class MediaService {
`Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`,
);
}
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
try {
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, tmpPath, command);
} catch (error) {
this.logger.error(error);
await this.storageRepository.unlink(tmpPath);
return JobStatus.FAILED;
}
}
await this.storageRepository.rename(tmpPath, output);
this.logger.log(`Successfully encoded ${asset.id}`);
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });

View File

@@ -438,60 +438,6 @@ 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', () => {
it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
@@ -605,10 +551,7 @@ describe(PersonService.name, () => {
await sut.handleQueueRecognizeFaces({});
expect(personMock.getAllFaces).toHaveBeenCalledWith(
{ skip: 0, take: 1000 },
{ where: { personId: IsNull(), isEdited: false } },
);
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.FACIAL_RECOGNITION,

View File

@@ -102,22 +102,6 @@ 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[]> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
const person = await this.findOrFail(personId);
@@ -147,34 +131,6 @@ export class PersonService {
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> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
@@ -431,7 +387,7 @@ export class PersonService {
}
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull(), isEdited: false } }),
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }),
);
for await (const page of facePagination) {

View File

@@ -18,7 +18,6 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
primaryFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId2',
@@ -33,7 +32,6 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
mergeFace1: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId3',
@@ -48,7 +46,6 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
mergeFace2: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId4',
@@ -63,7 +60,6 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
start: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId5',
@@ -78,7 +74,6 @@ export const faceStub = {
boundingBoxY2: 505,
imageHeight: 1000,
imageWidth: 1000,
isEdited: false,
}),
middle: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId6',
@@ -93,7 +88,6 @@ export const faceStub = {
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
isEdited: false,
}),
end: Object.freeze<NonNullableProperty<AssetFaceEntity>>({
id: 'assetFaceId7',
@@ -108,7 +102,6 @@ export const faceStub = {
boundingBoxY2: 495,
imageHeight: 500,
imageWidth: 500,
isEdited: false,
}),
noPerson1: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId8',
@@ -123,7 +116,6 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
isEdited: false,
}),
noPerson2: Object.freeze<AssetFaceEntity>({
id: 'assetFaceId9',
@@ -138,21 +130,5 @@ export const faceStub = {
boundingBoxY2: 1,
imageHeight: 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,
}),
};

View File

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

View File

@@ -74,7 +74,7 @@
// TODO: check if reloading asset data is necessary
if (newAsset.id && !isSharedLink()) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || undefined;
people = data?.people || [];
}
};
@@ -89,7 +89,7 @@
}
})();
$: people = asset?.people || undefined;
$: people = asset.people || [];
$: showingHiddenPeople = false;
onMount(() => {
@@ -117,7 +117,7 @@
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || undefined;
people = data?.people || [];
});
showEditFaces = false;
};
@@ -158,12 +158,12 @@
<DetailPanelDescription {asset} {isOwner} />
{#if !isSharedLink() && people?.numberOfFaces && people?.numberOfFaces > 0}
{#if !isSharedLink() && people.length > 0}
<section class="px-4 py-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<h2>PEOPLE</h2>
<div class="flex gap-2 items-center">
{#if people.visiblePeople.some((person) => person.isHidden)}
{#if people.some((person) => person.isHidden)}
<CircleIconButton
title="Show hidden people"
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
@@ -184,16 +184,16 @@
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people.visiblePeople as person (person.id)}
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
<a
class="w-[90px]"
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
: AppRoute.PHOTOS}"
on:focus={() => ($boundingBoxesArray = person.faces)}
on:focus={() => ($boundingBoxesArray = people[index].faces)}
on:blur={() => ($boundingBoxesArray = [])}
on:mouseover={() => ($boundingBoxesArray = person.faces)}
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
on:mouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
@@ -498,9 +498,9 @@
<PersonSidePanel
assetId={asset.id}
assetType={asset.type}
onClose={() => {
on:close={() => {
showEditFaces = false;
}}
onRefresh={handleRefreshPeople}
on:refresh={handleRefreshPeople}
/>
{/if}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'blue' | 'red' | 'green';
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
export let type: 'button' | 'submit' | 'reset' = 'button';
export let icon: string;
@@ -14,7 +14,6 @@
* viewBox attribute for the SVG icon.
*/
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.
@@ -30,9 +29,6 @@
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
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',
blue: 'bg-blue-700',
red: 'bg-red-700',
green: 'bg-green-700',
};
$: colorClass = colorClasses[color];
@@ -44,9 +40,7 @@
{type}
style:width={buttonSize ? buttonSize + 'px' : ''}
style:height={buttonSize ? buttonSize + 'px' : ''}
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all {disableHover
? ''
: 'hover:dark:text-immich-dark-gray'} {className} {mobileClass}"
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
on:click
>
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />

View File

@@ -1,25 +1,23 @@
<script lang="ts">
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { photoViewer } from '$lib/stores/assets.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { linear } from 'svelte/easing';
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 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 editedFace: AssetFaceResponseDto;
export let peopleWithFaces: AssetFaceResponseDto[];
export let allPeople: PersonResponseDto[];
export let editedPerson: PersonResponseDto;
export let assetType: AssetTypeEnum;
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
let isShowLoadingNewPerson = false;
@@ -32,20 +30,85 @@
$: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden);
const dispatch = createEventDispatcher<{
close: void;
createPerson: string | null;
reassign: PersonResponseDto;
}>();
const handleBackButton = () => {
onClose();
dispatch('close');
};
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 timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetType, assetId);
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
onCreatePerson(newFeaturePhoto);
dispatch('createPerson', newFeaturePhoto);
clearTimeout(timeout);
isShowLoadingNewPerson = false;
onCreatePerson(newFeaturePhoto);
dispatch('createPerson', newFeaturePhoto);
};
</script>
@@ -94,42 +157,33 @@
{/if}
</div>
<div class="px-4 py-4 text-sm">
{#if showPeople.length > 0}
<h2 class="mb-8 mt-4 uppercase">All people</h2>
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
{#each showPeople as person (person.id)}
{#if person.id !== editedPerson?.id}
<div class="w-fit">
<button type="button" class="w-[90px]" on:click={() => onReassign(person)}>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>
<h2 class="mb-8 mt-4 uppercase">All people</h2>
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
{#each showPeople as person (person.id)}
{#if person.id !== editedPerson.id}
<div class="w-fit">
<button type="button" class="w-[90px]" on:click={() => dispatch('reassign', person)}>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person.id)}
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
{person.name}
</p>
</button>
</div>
{/if}
{/each}
</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}
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
{person.name}
</p>
</button>
</div>
{/if}
{/each}
</div>
</div>
</section>

View File

@@ -5,51 +5,38 @@
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
import {
AssetTypeEnum,
createPerson,
getAllPeople,
getFaces,
reassignFacesById,
unassignFace,
type AssetFaceResponseDto,
type PersonResponseDto,
} from '@immich/sdk';
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiFaceMan, mdiMinus, mdiRestart } from '@mdi/js';
import { onMount } from 'svelte';
import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
import { linear } from 'svelte/easing';
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 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 assetType: AssetTypeEnum;
export let onClose = () => {};
export let onRefresh = () => {};
// keep track of the changes
let peopleToCreate: string[] = [];
let assetFaceGenerated: string[] = [];
// faces
let allFaces: AssetFaceResponseDto[] = [];
let peopleWithFaces: AssetFaceResponseDto[] = [];
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
let selectedPersonToCreate: Record<string, string> = {};
let selectedPersonToAdd: Record<string, FaceWithGeneratedThumbnail> = {};
let selectedFaceToRemove: Record<string, AssetFaceResponseDto> = {};
let editedPerson: PersonResponseDto;
let editedFace: AssetFaceResponseDto;
let unassignedFaces: FaceWithGeneratedThumbnail[] = [];
// loading spinners
let isShowLoadingDone = false;
@@ -57,39 +44,25 @@
// search people
let showSelectedFaces = false;
let showUnassignedFaces = false;
let allPeople: PersonResponseDto[] = [];
// timers
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
$: mapFacesToBeCreated = Object.entries(selectedPersonToAdd)
.filter(([_, value]) => value.person === null)
.map(([key, _]) => key);
const thumbnailWidth = '90px';
const generatePeopleWithoutFaces = async () => {
const peopleWithGeneratedImage = await Promise.all(
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);
};
const dispatch = createEventDispatcher<{
close: void;
refresh: void;
}>();
async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
try {
const { people } = await getAllPeople({ withHidden: true });
allPeople = people;
allFaces = await getFaces({ id: assetId });
peopleWithFaces = allFaces.filter((face) => face.person);
await generatePeopleWithoutFaces();
peopleWithFaces = await getFaces({ id: assetId });
} catch (error) {
handleError(error, "Can't get faces");
} finally {
@@ -98,26 +71,17 @@
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) => {
assetFaceGenerated.push(personId);
if (
isEqual(assetFaceGenerated, peopleToCreate) &&
isEqual(assetFaceGenerated, mapFacesToBeCreated) &&
loaderLoadingDoneTimeout &&
automaticRefreshTimeout
automaticRefreshTimeout &&
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
) {
clearTimeout(loaderLoadingDoneTimeout);
clearTimeout(automaticRefreshTimeout);
onRefresh();
dispatch('refresh');
}
};
@@ -130,6 +94,10 @@
return b.every((valueB) => a.includes(valueB));
};
const handleBackButton = () => {
dispatch('close');
};
const handleReset = (id: string) => {
if (selectedPersonToReassign[id]) {
delete selectedPersonToReassign[id];
@@ -145,22 +113,9 @@
}
};
const handleRemoveFace = (face: AssetFaceResponseDto) => {
selectedFaceToRemove[face.id] = face;
};
const handleAbortRemove = (id: string) => {
delete selectedFaceToRemove[id];
selectedFaceToRemove = selectedFaceToRemove;
};
const handleEditFaces = async () => {
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
const numberOfChanges =
Object.keys(selectedPersonToCreate).length +
Object.keys(selectedPersonToReassign).length +
Object.keys(selectedFaceToRemove).length +
Object.keys(selectedPersonToAdd).length;
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
if (numberOfChanges > 0) {
try {
@@ -182,28 +137,6 @@
}
}
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({
message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
type: NotificationType.Info,
@@ -216,9 +149,9 @@
isShowLoadingDone = false;
if (peopleToCreate.length === 0) {
clearTimeout(loaderLoadingDoneTimeout);
onRefresh();
dispatch('refresh');
} else {
automaticRefreshTimeout = setTimeout(() => onRefresh(), 15_000);
automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15_000);
}
};
@@ -243,27 +176,6 @@
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>
<section
@@ -272,252 +184,130 @@
>
<div class="flex place-items-center justify-between gap-2">
<div class="flex items-center gap-2">
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={onClose} />
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
</div>
{#if !isShowLoadingDone}
<div class="flex gap-2">
{#if peopleWithFaces.length > Object.keys(selectedFaceToRemove).length}
<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="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>
<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>
{:else}
<LoadingSpinner />
{/if}
</div>
{#if peopleWithFaces.length > 0}
<div class="px-4 py-4 text-sm">
<div class="mt-4 flex flex-wrap gap-2">
{#if isShowLoadingPeople}
<div class="flex w-full justify-center">
<LoadingSpinner />
</div>
{:else}
{#each peopleWithFaces as face, index}
{#if face.person && !selectedFaceToRemove[face.id]}
<div class="relative z-[20001] h-[115px] w-[95px]">
<div
role="button"
tabindex={index}
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
on:mouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
{#if selectedPersonToCreate[face.id]}
<ImageThumbnail
curve
shadow
url={selectedPersonToCreate[face.id]}
altText={selectedPersonToCreate[face.id]}
title={'New person'}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
/>
{:else if selectedPersonToReassign[face.id]}
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
title={getPersonNameWithHiddenValue(
selectedPersonToReassign[face.id].name,
face.person?.isHidden,
)}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
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}
{face.person?.name}
{/if}
</p>
{/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>
{/if}
{/each}
{/if}
</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">
<div class="mt-4 flex flex-wrap gap-2">
{#if isShowLoadingPeople}
<div class="flex w-full justify-center">
<LoadingSpinner />
</div>
{:else}
{#each peopleWithFaces as face, index}
{#if face.person}
<div class="relative z-[20001] h-[115px] w-[95px]">
<div
role="button"
tabindex={index}
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
on:mouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
{#if selectedPersonToCreate[face.id]}
<ImageThumbnail
curve
shadow
url={face.person ? getPeopleThumbnailUrl(face.person.id) : face.customThumbnail}
altText={'New person'}
url={selectedPersonToCreate[face.id]}
altText={selectedPersonToCreate[face.id]}
title={'New person'}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
/>
</div>
{#if face.person?.name}
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
{face.person?.name}
</p>{/if}
{:else if selectedPersonToReassign[face.id]}
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
title={getPersonNameWithHiddenValue(
selectedPersonToReassign[face.id].name,
face.person?.isHidden,
)}
widthStyle={thumbnailWidth}
heightStyle={thumbnailWidth}
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>
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
{#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}
{face.person?.name}
{/if}
</p>
{/if}
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full">
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
<CircleIconButton
color="red"
icon={mdiMinus}
color="primary"
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={() => handleRemoveAddedFace(face)}
size="18"
padding="1"
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
on:click={() => handleReset(face.id)}
/>
</div>
{: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}
</div>
</div>
{/if}
{/each}
</div>
</div>
{/if}
</div>
{/if}
{/each}
{/if}
</div>
</div>
</section>
{#if showSelectedFaces}
<AssignFaceSidePanel
{editedFace}
{peopleWithFaces}
{allPeople}
{editedPerson}
{assetType}
{assetId}
onClose={() => (showSelectedFaces = false)}
onCreatePerson={handleCreatePerson}
onReassign={handleReassignFace}
/>
{/if}
{#if showUnassignedFaces}
<UnassignedFacesSidePanel
{assetType}
{assetId}
{allPeople}
{unassignedFaces}
{selectedPersonToAdd}
{selectedFaceToRemove}
onResetFacesToBeRemoved={() => (selectedFaceToRemove = selectedFaceToRemove)}
onClose={() => (showUnassignedFaces = false)}
onCreatePerson={handleCreateOrReassignFaceFromUnassignedFace}
onReassign={handleCreateOrReassignFaceFromUnassignedFace}
onAbortRemove={handleAbortRemove}
on:close={() => (showSelectedFaces = false)}
on:createPerson={(event) => handleCreatePerson(event.detail)}
on:reassign={(event) => handleReassignFace(event.detail)}
/>
{/if}

View File

@@ -1,208 +0,0 @@
<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}

View File

@@ -6,11 +6,10 @@
createPerson,
getAllPeople,
reassignFaces,
unassignFaces,
type AssetFaceUpdateItem,
type PersonResponseDto,
} from '@immich/sdk';
import { mdiMerge, mdiPlus, mdiTagRemove } from '@mdi/js';
import { mdiMerge, mdiPlus } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -30,7 +29,6 @@
let disableButtons = false;
let showLoadingSpinnerCreate = false;
let showLoadingSpinnerReassign = false;
let showLoadingSpinnerUnassign = false;
let hasSelection = false;
let screenHeight: number;
@@ -114,24 +112,6 @@
showLoadingSpinnerReassign = false;
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>
<svelte:window bind:innerHeight={screenHeight} />
@@ -147,29 +127,16 @@
</svelte:fragment>
<svelte:fragment slot="trailing">
<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
title={'Assign selected assets to a new person'}
size={'sm'}
disabled={disableButtons || hasSelection}
on:click={handleCreate}
>
{#if showLoadingSpinnerCreate}
<LoadingSpinner />
{:else}
{#if !showLoadingSpinnerCreate}
<Icon path={mdiPlus} size={18} />
{:else}
<LoadingSpinner />
{/if}
<span class="ml-2"> Create new Person</span></Button
>
@@ -179,12 +146,12 @@
disabled={disableButtons || !hasSelection}
on:click={handleReassign}
>
{#if showLoadingSpinnerReassign}
<LoadingSpinner />
{:else}
{#if !showLoadingSpinnerReassign}
<div>
<Icon path={mdiMerge} size={18} class="rotate-180" />
</div>
{:else}
<LoadingSpinner />
{/if}
<span class="ml-2"> Reassign</span></Button
>

View File

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

View File

@@ -1,7 +1,4 @@
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';
import type { PersonResponseDto } from '@immich/sdk';
export const searchNameLocal = (
name: string,
@@ -31,60 +28,3 @@ export const searchNameLocal = (
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
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;
}
};

View File

@@ -45,7 +45,7 @@ export function getAltText(asset: AssetResponseDto) {
altText += ` in ${asset.exifInfo.city}, ${asset.exifInfo.country}`;
}
const names = asset.people?.visiblePeople.filter((p) => p.name).map((p) => p.name) ?? [];
const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? [];
if (names.length == 1) {
altText += ` with ${names[0]}`;
}