feat: create new person in face editor (#27364)

* feat: create new person in face editor

* add delay

* fix: test

* i18n

* fix: unit test

* pr feedback
This commit is contained in:
Alex
2026-04-02 10:28:40 -05:00
committed by GitHub
parent b465f2b58f
commit 37823bcd51
6 changed files with 274 additions and 7 deletions
@@ -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/CreateFaceModal.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<ReturnType<typeof getFaceCroppedCoordinates>>;
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,19 +364,19 @@
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
class="absolute inset-s-0 top-0 z-5 h-full w-full overflow-hidden"
data-overlay-interactive
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
data-face-height={faceBoxPosition.height}
>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 inset-s-0"></canvas>
<div
id="face-selector"
bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
class="absolute top-[calc(50%-250px)] inset-s-[calc(50%-125px)] max-w-62.5 w-62.5 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
@@ -354,6 +415,12 @@
{/if}
</div>
<Button size="small" fullWidth onclick={onClose} color="danger" class="mt-2">{$t('cancel')}</Button>
<Button size="small" fullWidth onclick={showCreateFaceModal} variant="outline" class="mt-2">
{$t('create_person')}
</Button>
<Button size="small" fullWidth onclick={onClose} color="danger" class="mt-2">
{$t('cancel')}
</Button>
</div>
</div>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, createPerson } from '@immich/sdk';
import { Field, FormModal, Input, LoadingSpinner, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
assetId: string;
imageWidth: number;
imageHeight: number;
x: number;
y: number;
width: number;
height: number;
onClose: (created?: boolean) => void;
previewUrl?: string;
};
let { assetId, imageWidth, imageHeight, x, y, width, height, onClose, previewUrl }: Props = $props();
let personName = $state('');
let isSubmitting = $state(false);
const getTrimmedName = () => personName.trim();
const onSubmit = async () => {
const name = getTrimmedName();
if (!name) {
return;
}
try {
isSubmitting = true;
const person = await createPerson({
personCreateDto: {
name,
},
});
await createFace({
assetFaceCreateDto: {
assetId,
imageHeight,
imageWidth,
personId: person.id,
x,
y,
width,
height,
},
});
await delay(1500);
await assetViewerManager.setAssetId(assetId);
onClose(true);
} catch (error) {
handleError(error, 'Error creating and tagging face');
} finally {
isSubmitting = false;
}
};
</script>
<FormModal
size="small"
title={$t('create_person')}
submitText={$t('tag_face')}
disabled={!getTrimmedName() || isSubmitting}
{onClose}
{onSubmit}
>
<Text size="tiny" class="mb-4" color="muted">{$t('create_person_subtitle')}</Text>
{#if previewUrl}
<Field label={$t('preview')}>
<div class="flex justify-center rounded-xl bg-gray-50 p-3 dark:border-gray-700 dark:bg-black/20 relative">
<img src={previewUrl} alt={$t('preview')} class="max-h-48 rounded-lg object-contain shadow-sm" />
{#if isSubmitting}
<div class="flex place-items-center place-content-center absolute inset-0 bg-black/20 rounded-lg">
<LoadingSpinner />
</div>
{/if}
</div>
</Field>
{/if}
<Field label={$t('name')} required class="mt-3">
<Input autofocus bind:value={personName} disabled={isSubmitting} />
</Field>
</FormModal>