diff --git a/mobile/openapi/doc/PersonUpdateDto.md b/mobile/openapi/doc/PersonUpdateDto.md index 30451b8c59..7496b2af62 100644 Binary files a/mobile/openapi/doc/PersonUpdateDto.md and b/mobile/openapi/doc/PersonUpdateDto.md differ diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 488b9abd55..3d15c71565 100644 Binary files a/mobile/openapi/lib/model/person_update_dto.dart and b/mobile/openapi/lib/model/person_update_dto.dart differ diff --git a/mobile/openapi/test/person_update_dto_test.dart b/mobile/openapi/test/person_update_dto_test.dart index 6e09d86028..be3b8fb741 100644 Binary files a/mobile/openapi/test/person_update_dto_test.dart and b/mobile/openapi/test/person_update_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 7717e0ab1e..fec62de630 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -5816,12 +5816,14 @@ "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "description": "Person name." + }, + "featureFaceAssetId": { + "type": "string", + "description": "Asset is used to get the feature face thumbnail." } - }, - "required": [ - "name" - ] + } }, "QueueStatusDto": { "type": "object", diff --git a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts index 87418b2c23..2d4de56378 100644 --- a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts +++ b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts @@ -191,6 +191,12 @@ describe(FacialRecognitionService.name, () => { personId: 'person-1', assetId: 'asset-id', embedding: [1, 2, 3, 4], + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageHeight: 500, + imageWidth: 400, }); }); @@ -207,6 +213,12 @@ describe(FacialRecognitionService.name, () => { personId: 'person-1', assetId: 'asset-id', embedding: [1, 2, 3, 4], + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageHeight: 500, + imageWidth: 400, }); expect(jobMock.queue.mock.calls).toEqual([ [ diff --git a/server/src/domain/facial-recognition/facial-recognition.services.ts b/server/src/domain/facial-recognition/facial-recognition.services.ts index a8559c6bc0..69bcec2bdf 100644 --- a/server/src/domain/facial-recognition/facial-recognition.services.ts +++ b/server/src/domain/facial-recognition/facial-recognition.services.ts @@ -83,7 +83,16 @@ export class FacialRecognitionService { const faceId: AssetFaceId = { assetId: asset.id, personId }; - await this.faceRepository.create({ ...faceId, embedding }); + await this.faceRepository.create({ + ...faceId, + embedding, + imageHeight: rest.imageHeight, + imageWidth: rest.imageWidth, + boundingBoxX1: rest.boundingBox.x1, + boundingBoxX2: rest.boundingBox.x2, + boundingBoxY1: rest.boundingBox.y1, + boundingBoxY2: rest.boundingBox.y2, + }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId }); } diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index 19185dc930..1790697e4e 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,10 +1,20 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; export class PersonUpdateDto { - @IsNotEmpty() + /** + * Person name. + */ + @IsOptional() @IsString() - name!: string; + name?: string; + + /** + * Asset is used to get the feature face thumbnail. + */ + @IsOptional() + @IsString() + featureFaceAssetId?: string; } export class PersonResponseDto { diff --git a/server/src/domain/person/person.repository.ts b/server/src/domain/person/person.repository.ts index a9ae9dcbea..0f05e3d98b 100644 --- a/server/src/domain/person/person.repository.ts +++ b/server/src/domain/person/person.repository.ts @@ -1,5 +1,5 @@ -import { AssetEntity, PersonEntity } from '@app/infra/entities'; - +import { AssetFaceId } from '@app/domain'; +import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities'; export const IPersonRepository = 'IPersonRepository'; export interface PersonSearchOptions { @@ -16,4 +16,6 @@ export interface IPersonRepository { update(entity: Partial): Promise; delete(entity: PersonEntity): Promise; deleteAll(): Promise; + + getFaceById(payload: AssetFaceId): Promise; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 6d46b3a978..3d786f9e25 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { assetEntityStub, authStub, + faceStub, newJobRepositoryMock, newPersonRepositoryMock, newStorageRepositoryMock, @@ -108,6 +109,36 @@ describe(PersonService.name, () => { data: { ids: [assetEntityStub.image.id] }, }); }); + + it("should update a person's thumbnailPath", async () => { + personMock.getById.mockResolvedValue(personStub.withName); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + + await expect( + sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), + ).resolves.toEqual(responseDto); + + expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); + expect(personMock.getFaceById).toHaveBeenCalledWith({ + assetId: faceStub.face1.assetId, + personId: 'person-1', + }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_FACE_THUMBNAIL, + data: { + assetId: faceStub.face1.assetId, + personId: 'person-1', + boundingBox: { + x1: faceStub.face1.boundingBoxX1, + x2: faceStub.face1.boundingBoxX2, + y1: faceStub.face1.boundingBoxY1, + y2: faceStub.face1.boundingBoxY2, + }, + imageHeight: faceStub.face1.imageHeight, + imageWidth: faceStub.face1.imageWidth, + }, + }); + }); }); describe('handlePersonCleanup', () => { diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 48075bee87..ed443f765c 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -1,3 +1,4 @@ +import { PersonEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; @@ -52,18 +53,54 @@ export class PersonService { } async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise { - const exists = await this.repository.getById(authUser.id, personId); - if (!exists) { + let person = await this.repository.getById(authUser.id, personId); + if (!person) { throw new BadRequestException(); } - const person = await this.repository.update({ id: personId, name: dto.name }); + if (dto.name) { + person = await this.updateName(authUser, personId, dto.name); + } + + if (dto.featureFaceAssetId) { + await this.updateFaceThumbnail(personId, dto.featureFaceAssetId); + } + + return mapPerson(person); + } + + private async updateName(authUser: AuthUserDto, personId: string, name: string): Promise { + const person = await this.repository.update({ id: personId, name }); const relatedAsset = await this.getAssets(authUser, personId); const assetIds = relatedAsset.map((asset) => asset.id); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } }); - return mapPerson(person); + return person; + } + + private async updateFaceThumbnail(personId: string, assetId: string): Promise { + const face = await this.repository.getFaceById({ assetId, personId }); + + if (!face) { + throw new BadRequestException(); + } + + return await this.jobRepository.queue({ + name: JobName.GENERATE_FACE_THUMBNAIL, + data: { + assetId: assetId, + personId, + boundingBox: { + x1: face.boundingBoxX1, + x2: face.boundingBoxX2, + y1: face.boundingBoxY1, + y2: face.boundingBoxY2, + }, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, + }, + }); } async handlePersonCleanup() { diff --git a/server/src/infra/entities/asset-face.entity.ts b/server/src/infra/entities/asset-face.entity.ts index 96aa17a2ca..cf59bc0c62 100644 --- a/server/src/infra/entities/asset-face.entity.ts +++ b/server/src/infra/entities/asset-face.entity.ts @@ -17,6 +17,24 @@ export class AssetFaceEntity { }) embedding!: number[] | null; + @Column({ default: 0, type: 'int' }) + imageWidth!: number; + + @Column({ default: 0, type: 'int' }) + imageHeight!: number; + + @Column({ default: 0, type: 'int' }) + boundingBoxX1!: number; + + @Column({ default: 0, type: 'int' }) + boundingBoxY1!: number; + + @Column({ default: 0, type: 'int' }) + boundingBoxX2!: number; + + @Column({ default: 0, type: 'int' }) + boundingBoxY2!: number; + @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) asset!: AssetEntity; diff --git a/server/src/infra/migrations/1688241394489-AddDetectFaceResultInfo.ts b/server/src/infra/migrations/1688241394489-AddDetectFaceResultInfo.ts new file mode 100644 index 0000000000..b026685bfb --- /dev/null +++ b/server/src/infra/migrations/1688241394489-AddDetectFaceResultInfo.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDetectFaceResultInfo1688241394489 implements MigrationInterface { + name = 'AddDetectFaceResultInfo1688241394489'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageWidth" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageHeight" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX1" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY1" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX2" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY2" integer NOT NULL DEFAULT '0'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY2"`); + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX2"`); + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY1"`); + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX1"`); + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageHeight"`); + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageWidth"`); + } +} diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index 8294e0d275..c4a04acab1 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -1,4 +1,4 @@ -import { IPersonRepository, PersonSearchOptions } from '@app/domain'; +import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; @@ -77,4 +77,8 @@ export class PersonRepository implements IPersonRepository { const { id } = await this.personRepository.save(entity); return this.personRepository.findOneByOrFail({ id }); } + + async getFaceById({ personId, assetId }: AssetFaceId): Promise { + return this.assetFaceRepository.findOneBy({ assetId, personId }); + } } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index f8dc7a7581..f1adb8a761 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -1157,6 +1157,16 @@ export const personStub = { thumbnailPath: '', faces: [], }), + newThumbnail: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userEntityStub.admin.id, + owner: userEntityStub.admin, + name: '', + thumbnailPath: '/new/path/to/thumbnail', + faces: [], + }), }; export const partnerStub = { @@ -1185,6 +1195,12 @@ export const faceStub = { personId: personStub.withName.id, person: personStub.withName, embedding: [1, 2, 3, 4], + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, }), }; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 0d02195772..68cd833edc 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -11,5 +11,7 @@ export const newPersonRepositoryMock = (): jest.Mocked => { update: jest.fn(), deleteAll: jest.fn(), delete: jest.fn(), + + getFaceById: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 1393f5c208..9cacbed266 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1772,11 +1772,17 @@ export interface PersonResponseDto { */ export interface PersonUpdateDto { /** - * + * Person name. * @type {string} * @memberof PersonUpdateDto */ - 'name': string; + 'name'?: string; + /** + * Asset is used to get the feature face thumbnail. + * @type {string} + * @memberof PersonUpdateDto + */ + 'featureFaceAssetId'?: string; } /** * diff --git a/web/src/lib/components/faces-page/face-thumbnail-selector.svelte b/web/src/lib/components/faces-page/face-thumbnail-selector.svelte new file mode 100644 index 0000000000..5d75c21b7c --- /dev/null +++ b/web/src/lib/components/faces-page/face-thumbnail-selector.svelte @@ -0,0 +1,36 @@ + + +
+ + Select feature photo + +
+ +
+
diff --git a/web/src/lib/components/shared-components/gallery-viewer/asset-selection-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/asset-selection-viewer.svelte new file mode 100644 index 0000000000..070d56bfb5 --- /dev/null +++ b/web/src/lib/components/shared-components/gallery-viewer/asset-selection-viewer.svelte @@ -0,0 +1,44 @@ + + +{#if assets.length > 0} +
+ {#each assets as asset (asset.id)} +
+ +
+ {/each} +
+{/if} diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 2f2737d151..6281583b90 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -10,6 +10,7 @@ import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import { flip } from 'svelte/animate'; import { archivedAsset } from '$lib/stores/archived-asset.store'; + import { getThumbnailSize } from '$lib/utils/thumbnail-util'; export let assets: AssetResponseDto[]; export let sharedLink: SharedLinkResponseDto | undefined = undefined; @@ -24,22 +25,10 @@ let currentViewAssetIndex = 0; let viewWidth: number; - let thumbnailSize = 300; + $: thumbnailSize = getThumbnailSize(assets.length, viewWidth); $: isMultiSelectionMode = selectedAssets.size > 0; - $: { - if (assets.length < 6) { - thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length)); - } else { - if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 7 - 7); - else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6); - else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6); - else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6); - else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6); - } - } - const viewAssetHandler = (event: CustomEvent) => { const { asset }: { asset: AssetResponseDto } = event.detail; diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts new file mode 100644 index 0000000000..07c99ddb44 --- /dev/null +++ b/web/src/lib/utils/thumbnail-util.ts @@ -0,0 +1,19 @@ +/** + * Calculate thumbnail size based on number of assets and viewport width + * @param assetCount Number of assets in the view + * @param viewWidth viewport width + * @returns thumbnail size + */ +export function getThumbnailSize(assetCount: number, viewWidth: number): number { + if (assetCount < 6) { + return Math.min(320, Math.floor(viewWidth / assetCount - assetCount)); + } else { + if (viewWidth > 600) return viewWidth / 7 - 7; + else if (viewWidth > 400) return viewWidth / 4 - 6; + else if (viewWidth > 300) return viewWidth / 2 - 6; + else if (viewWidth > 200) return viewWidth / 2 - 6; + else if (viewWidth > 100) return viewWidth / 1 - 6; + } + + return 300; +} diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index 53523e4b8b..47f3999157 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -22,10 +22,17 @@ import type { PageData } from './$types'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import SelectAll from 'svelte-material-icons/SelectAll.svelte'; + import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import FaceThumbnailSelector from '$lib/components/faces-page/face-thumbnail-selector.svelte'; + import { + NotificationType, + notificationController, + } from '$lib/components/shared-components/notification/notification'; export let data: PageData; - let isEditName = false; + let isEditingName = false; + let isSelectingFace = false; let previousRoute: string = AppRoute.EXPLORE; let selectedAssets: Set = new Set(); $: isMultiSelectionMode = selectedAssets.size > 0; @@ -41,7 +48,7 @@ const handleNameChange = async (name: string) => { try { - isEditName = false; + isEditingName = false; data.person.name = name; await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { name } }); } catch (error) { @@ -55,6 +62,25 @@ const handleSelectAll = () => { selectedAssets = new Set(data.assets); }; + + const handleSelectFeaturePhoto = async (event: CustomEvent) => { + isSelectingFace = false; + + const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail; + + if (selectedAsset) { + await api.personApi.updatePerson({ + id: data.person.id, + personUpdateDto: { featureFaceAssetId: selectedAsset.id }, + }); + + // TODO: Replace by Websocket in the future + notificationController.show({ + message: 'Feature photo updated, refresh page to see changes', + type: NotificationType.Info, + }); + } + }; {#if isMultiSelectionMode} @@ -73,30 +99,39 @@ {:else} - goto(previousRoute)} /> + goto(previousRoute)}> + + + (isSelectingFace = true)} /> + + + {/if}
- {#if isEditName} + {#if isEditingName} handleNameChange(event.detail)} - on:cancel={() => (isEditName = false)} + on:cancel={() => (isEditingName = false)} /> {:else} - + +
-
-
-
- +{#if !isSelectingFace} +
+
+
+ +
-
+{/if} + +{#if isSelectingFace} + +{/if}