From cb01b7234b405bf25f7c38439d14b00c9d0fd18d Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 28 Mar 2026 23:23:10 +0000 Subject: [PATCH 1/6] feat: create new person in face editor --- i18n/en.json | 4 + server/src/services/person.service.spec.ts | 80 ++++++++++++++++++ server/src/services/person.service.ts | 10 ++- .../face-editor/face-editor.svelte | 75 ++++++++++++++++- web/src/lib/modals/FaceCreateTagModal.svelte | 84 +++++++++++++++++++ 5 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/modals/FaceCreateTagModal.svelte diff --git a/i18n/en.json b/i18n/en.json index 42a89586f9444..8982d421b2f27 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -849,9 +849,12 @@ "create_link_to_share": "Create link to share", "create_link_to_share_description": "Let anyone with the link see the selected photo(s)", "create_new": "CREATE NEW", + "create_new_face": "Create new face", "create_new_person": "Create new person", "create_new_person_hint": "Assign selected assets to a new person", "create_new_user": "Create new user", + "create_person": "Create person", + "create_person_subtitle": "Add a name to the selected face to create a new person", "create_shared_album_page_share_add_assets": "ADD ASSETS", "create_shared_album_page_share_select_photos": "Select Photos", "create_shared_link": "Create shared link", @@ -2214,6 +2217,7 @@ "tag": "Tag", "tag_assets": "Tag assets", "tag_created": "Created tag: {tag}", + "tag_face": "Tag face", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", "tag_not_found_question": "Cannot find a tag? Create a new tag.", "tag_people": "Tag People", diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 5c262d892d1f9..dc34078271c82 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -370,6 +370,86 @@ describe(PersonService.name, () => { }); }); + describe('createFace', () => { + it('should create a manual face and initialize the person feature photo when missing', async () => { + const auth = AuthFactory.create(); + const asset = AssetFactory.create(); + const person = PersonFactory.create({ faceAssetId: null }); + const featureFace = AssetFaceFactory.create({ + assetId: asset.id, + personId: person.id, + sourceType: SourceType.Manual, + }); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.asset.getById.mockResolvedValue(asset); + mocks.person.getById.mockResolvedValue(person); + mocks.person.getRandomFace.mockResolvedValue(featureFace); + mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id }); + + await expect( + sut.createFace(auth, { + assetId: asset.id, + personId: person.id, + imageHeight: 500, + imageWidth: 400, + x: 10, + y: 20, + width: 100, + height: 110, + }), + ).resolves.toBeUndefined(); + + expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true }); + expect(mocks.person.createAssetFace).toHaveBeenCalledWith({ + assetId: asset.id, + personId: person.id, + imageHeight: 500, + imageWidth: 400, + boundingBoxX1: 10, + boundingBoxX2: 110, + boundingBoxY1: 20, + boundingBoxY2: 130, + sourceType: SourceType.Manual, + }); + expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id); + expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id }); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.PersonGenerateThumbnail, data: { id: person.id } }, + ]); + }); + + it('should not update the person feature photo if one already exists', async () => { + const auth = AuthFactory.create(); + const asset = AssetFactory.create(); + const person = PersonFactory.create({ faceAssetId: newUuid() }); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.asset.getById.mockResolvedValue(asset); + mocks.person.getById.mockResolvedValue(person); + + await expect( + sut.createFace(auth, { + assetId: asset.id, + personId: person.id, + imageHeight: 500, + imageWidth: 400, + x: 10, + y: 20, + width: 100, + height: 110, + }), + ).resolves.toBeUndefined(); + + expect(mocks.person.createAssetFace).toHaveBeenCalledOnce(); + expect(mocks.person.getRandomFace).not.toHaveBeenCalled(); + expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.job.queueAll).not.toHaveBeenCalled(); + }); + }); + describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { const person = PersonFactory.create(); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index fb04ace4f2538..cc92283053fad 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -631,7 +631,11 @@ export class PersonService extends BaseService { this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }), ]); - const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }); + const [asset, person] = await Promise.all([ + this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }), + this.findOrFail(dto.personId), + ]); + if (!asset) { throw new NotFoundException('Asset not found'); } @@ -689,6 +693,10 @@ export class PersonService extends BaseService { boundingBoxY2: Math.round(bottomRight.y), sourceType: SourceType.Manual, }); + + if (person.faceAssetId === null) { + await this.createNewFeaturePhoto([person.id]); + } } async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise { diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index ea2babfc11714..17b92cb1002a9 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -2,6 +2,7 @@ import { shortcut } from '$lib/actions/shortcut'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import FaceCreateTagModal from '$lib/modals/FaceCreateTagModal.svelte'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; @@ -260,6 +261,44 @@ }; }; + type FaceCoordinates = NonNullable>; + + const getFacePreviewUrl = (data: FaceCoordinates) => { + if (!htmlElement) { + return; + } + + const natural = getNaturalSize(htmlElement); + if (natural.width <= 0 || natural.height <= 0) { + return; + } + + const x = clamp(data.x, 0, natural.width - 1); + const y = clamp(data.y, 0, natural.height - 1); + const width = clamp(data.width, 1, natural.width - x); + const height = clamp(data.height, 1, natural.height - y); + + if (width <= 0 || height <= 0) { + return; + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + try { + context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height); + return canvas.toDataURL('image/png'); + } catch { + return; + } + }; + const tagFace = async (person: PersonResponseDto) => { try { const data = getFaceCroppedCoordinates(); @@ -294,6 +333,28 @@ } }; + const showCreateFaceModal = async () => { + try { + const data = getFaceCroppedCoordinates(); + if (!data) { + return; + } + + const created = await modalManager.show(FaceCreateTagModal, { + assetId, + ...data, + previewUrl: getFacePreviewUrl(data), + }); + if (!created) { + return; + } + + onClose(); + } catch (error) { + handleError(error, 'Error creating and tagging face'); + } + }; + onDestroy(() => { onClose(); }); @@ -303,18 +364,18 @@
- +

{$t('select_person_to_tag')}

@@ -353,6 +414,12 @@ {/if}
- + + +
diff --git a/web/src/lib/modals/FaceCreateTagModal.svelte b/web/src/lib/modals/FaceCreateTagModal.svelte new file mode 100644 index 0000000000000..e36dde3afbf26 --- /dev/null +++ b/web/src/lib/modals/FaceCreateTagModal.svelte @@ -0,0 +1,84 @@ + + + + {$t('create_person_subtitle')} + {#if previewUrl} + +
+ {$t('preview')} +
+
+ {/if} + + + + +
From efedef581824e27521325af6b66897753c57bc08 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 28 Mar 2026 23:43:25 +0000 Subject: [PATCH 2/6] add delay --- web/src/lib/modals/FaceCreateTagModal.svelte | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/src/lib/modals/FaceCreateTagModal.svelte b/web/src/lib/modals/FaceCreateTagModal.svelte index e36dde3afbf26..fa845cba2d517 100644 --- a/web/src/lib/modals/FaceCreateTagModal.svelte +++ b/web/src/lib/modals/FaceCreateTagModal.svelte @@ -1,8 +1,9 @@