refactor: asset face queries (#32679)

(cherry picked from commit 5077a85d5e6775c48b38de7fcf25f2a3864b6a83)
This commit is contained in:
Jason Rasmussen
2026-10-27 23:11:20 +03:00
committed by Codex
parent 8125af4859
commit a3ffe3b3c3
5 changed files with 123 additions and 120 deletions
+2 -2
View File
@@ -93,7 +93,7 @@
@layer utilities {
@font-face {
font-family: 'GoogleSans';
src: url('$lib/assets/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
src: url('#lib/assets/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
font-weight: 410 900;
font-style: normal;
ascent-override: 106.25%;
@@ -102,7 +102,7 @@
@font-face {
font-family: 'GoogleSansCode';
src: url('$lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
src: url('#lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
font-weight: 1 900;
font-style: monospace;
}
@@ -3,12 +3,14 @@
import ImageThumbnail from '#lib/components/assets/thumbnail/ImageThumbnail.svelte';
import { assetViewerManager } from '#lib/managers/asset-viewer-manager.svelte.ts';
import FaceCreateTagModal from '#lib/modals/CreateFaceModal.svelte';
import { executeMutation } from '#lib/queries/client.ts';
import { createFaceMutation } from '#lib/queries/face.query.ts';
import { faceManager } from '#lib/stores/face.svelte.ts';
import { getPeopleThumbnailUrl } from '#lib/utils.ts';
import { getNaturalSize, scaleToFit } from '#lib/utils/container-utils.ts';
import { handleError } from '#lib/utils/handle-error.ts';
import { normalizeSearchString } from '#lib/utils/string-utils.ts';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
@@ -304,38 +306,29 @@
};
const tagFace = async (person: PersonResponseDto) => {
try {
const data = getFaceCroppedCoordinates();
if (!data) {
toastManager.warning($t('error_tag_face_bounding_box'));
return;
}
const data = getFaceCroppedCoordinates();
if (!data) {
toastManager.warning($t('error_tag_face_bounding_box'));
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: person.name
? $t('confirm_tag_face', { values: { name: person.name } })
: $t('confirm_tag_face_unnamed'),
});
const isConfirmed = await modalManager.showDialog({
prompt: person.name ? $t('confirm_tag_face', { values: { name: person.name } }) : $t('confirm_tag_face_unnamed'),
});
if (!isConfirmed) {
return;
}
await createFace({
assetFaceCreateDto: {
assetId,
personId: person.id,
...data,
},
});
if (!isConfirmed) {
return;
}
const onSuccess = async () => {
await assetViewerManager.setAssetId(assetId);
faceManager.clear();
onClose();
} catch (error) {
handleError(error, 'Error tagging face');
onClose();
}
};
await executeMutation(createFaceMutation, [{ assetFaceCreateDto: { assetId, personId: person.id, ...data } }], {
onSuccess: () => void onSuccess(),
onSettled: onClose,
});
};
const showCreateFaceModal = async () => {
@@ -5,22 +5,16 @@
import { assetViewerManager } from '#lib/managers/asset-viewer-manager.svelte.ts';
import { eventManager } from '#lib/managers/event-manager.svelte.ts';
import { languageManager } from '#lib/managers/language-manager.svelte.ts';
import { getPeopleThumbnailUrl, handlePromiseError } from '#lib/utils.ts';
import { executeMutation } from '#lib/queries/client.ts';
import { deleteFaceMutation, getFacesQuery, reassignFacesByIdMutation } from '#lib/queries/face.query.ts';
import { getPeopleThumbnailUrl } from '#lib/utils.ts';
import { handleError } from '#lib/utils/handle-error.ts';
import { zoomImageToBase64 } from '#lib/utils/people-utils.ts';
import { getPersonNameWithHiddenValue } from '#lib/utils/person.ts';
import {
AssetTypeEnum,
createPerson,
deleteFace,
getFaces,
reassignFacesById,
type AssetFaceResponseDto,
type PersonResponseDto,
} from '@immich/sdk';
import { AssetTypeEnum, createPerson, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, toastManager } from '@immich/ui';
import { mdiAccountOff, mdiArrowLeftThin, mdiArrowRightThin, mdiPencil, mdiRestart, mdiTrashCan } from '@mdi/js';
import { onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
@@ -40,15 +34,14 @@
let peopleToCreate: string[] = [];
let assetFaceGenerated: string[] = [];
// faces
let peopleWithFaces: AssetFaceResponseDto[] = $state([]);
const query = createQuery(() => getFacesQuery(assetId));
const peopleWithFaces = $derived(query.data ?? []);
let selectedPersonToReassign: Record<string, PersonResponseDto> = $state({});
let selectedPersonToCreate: Record<string, string> = $state({});
let editedFace: AssetFaceResponseDto | undefined = $state();
// loading spinners
let isShowLoadingDone = $state(false);
let isShowLoadingPeople = $state(false);
// search people
let showSelectedFaces = $state(false);
@@ -59,18 +52,6 @@
const thumbnailWidth = '90px';
async function loadPeople() {
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
try {
peopleWithFaces = await getFaces({ id: assetId });
} catch (error) {
handleError(error, $t('errors.cant_get_faces'));
} finally {
clearTimeout(timeout);
}
isShowLoadingPeople = false;
}
const onPersonThumbnailReady = ({ id }: { id: string }) => {
assetFaceGenerated.push(id);
if (
@@ -85,10 +66,6 @@
}
};
onMount(() => {
handlePromiseError(loadPeople());
});
const isEqual = (a: string[], b: string[]): boolean => {
return b.every((valueB) => a.includes(valueB));
};
@@ -107,28 +84,36 @@
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
if (numberOfChanges > 0) {
let failed = false;
try {
for (const personWithFace of peopleWithFaces) {
const personId = selectedPersonToReassign[personWithFace.id]?.id;
let personId = selectedPersonToReassign[personWithFace.id]?.id;
if (personId) {
await reassignFacesById({
id: personId,
faceDto: { id: personWithFace.id },
});
} else if (Object.hasOwn(selectedPersonToCreate, personWithFace.id)) {
if (!personId && Object.hasOwn(selectedPersonToCreate, personWithFace.id)) {
const data = await createPerson({ personCreateDto: {} });
peopleToCreate.push(data.id);
await reassignFacesById({
id: data.id,
faceDto: { id: personWithFace.id },
});
personId = data.id;
}
if (!personId) {
continue;
}
await executeMutation(reassignFacesByIdMutation, [{ id: personId, faceDto: { id: personWithFace.id } }], {
onError: () => (failed = true),
});
if (failed) {
break;
}
}
toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } }));
} catch (error) {
handleError(error, $t('errors.cant_apply_changes'));
failed = true;
}
if (!failed) {
toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } }));
}
}
@@ -161,28 +146,24 @@
};
const deleteAssetFace = async (face: AssetFaceResponseDto) => {
try {
if (!face.person) {
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: $t('confirm_delete_face', { values: { name: face.person.name } }),
});
if (!isConfirmed) {
return;
}
await deleteFace({ id: face.id, assetFaceDeleteDto: { force: false } });
eventManager.emit('PersonAssetDelete', { id: face.person.id, assetId });
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
const person = face.person;
if (!person) {
return;
}
const isConfirmed = await modalManager.showDialog({
prompt: $t('confirm_delete_face', { values: { name: person.name } }),
});
if (!isConfirmed) {
return;
}
await executeMutation(deleteFaceMutation, [{ id: face.id, assetFaceDeleteDto: { force: false } }], {
onSuccess: () => {
eventManager.emit('PersonAssetDelete', { id: person.id, assetId });
void assetViewerManager.setAssetId(assetId);
},
});
};
</script>
@@ -232,7 +213,7 @@
<div class="p-4 text-sm">
<div class="mt-4 flex flex-wrap gap-2">
{#if isShowLoadingPeople}
{#if query.isPending}
<div class="flex w-full justify-center">
<LoadingSpinner />
</div>
+21 -25
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { assetViewerManager } from '#lib/managers/asset-viewer-manager.svelte.ts';
import { executeMutation } from '#lib/queries/client.ts';
import { createFaceMutation } from '#lib/queries/face.query.ts';
import { delay } from '#lib/utils/asset-utils.ts';
import { handleError } from '#lib/utils/handle-error.ts';
import { createFace, createPerson } from '@immich/sdk';
import { createPerson } from '@immich/sdk';
import { Field, FormModal, Input, LoadingSpinner, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -30,36 +32,30 @@
return;
}
isSubmitting = true;
let personId: string;
try {
isSubmitting = true;
const person = await createPerson({
personCreateDto: {
name,
},
});
await createFace({
assetFaceCreateDto: {
assetId,
imageHeight,
imageWidth,
personId: person.id,
x,
y,
width,
height,
},
});
const person = await createPerson({ personCreateDto: { name } });
personId = person.id;
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
isSubmitting = false;
return;
}
const onSuccess = async () => {
await delay(1500);
await assetViewerManager.setAssetId(assetId);
onClose(true);
} catch (error) {
handleError(error, 'Error creating and tagging face');
} finally {
isSubmitting = false;
}
};
const assetFaceCreateDto = { assetId, imageHeight, imageWidth, personId, x, y, width, height };
await executeMutation(createFaceMutation, [{ assetFaceCreateDto }], {
onSuccess: () => void onSuccess(),
onError: () => (isSubmitting = false),
});
};
</script>
+33
View File
@@ -0,0 +1,33 @@
import { asMutation, queryClient } from '#lib/queries/client.ts';
import { createFace, deleteFace, getFaces, reassignFacesById, type AssetFaceResponseDto } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const getFacesQuery = (assetId: string) =>
queryOptions({
queryKey: ['faces', assetId],
queryFn: ({ signal }) => getFaces({ id: assetId }, { signal }),
});
export const createFaceMutation = () =>
asMutation(createFace, {
onSuccess: (_, [{ assetFaceCreateDto }]) =>
queryClient.invalidateQueries({ queryKey: getFacesQuery(assetFaceCreateDto.assetId).queryKey }),
});
export const deleteFaceMutation = () =>
asMutation(deleteFace, {
onSuccess: (_, [{ id }]) =>
queryClient.setQueriesData<AssetFaceResponseDto[]>({ queryKey: ['faces'] }, (faces) =>
faces?.filter((face) => face.id !== id),
),
meta: { error: { toast: 'error_delete_face' } },
});
export const reassignFacesByIdMutation = () =>
asMutation(reassignFacesById, {
onSuccess: (person, [{ faceDto }]) =>
queryClient.setQueriesData<AssetFaceResponseDto[]>({ queryKey: ['faces'] }, (faces) =>
faces?.map((face) => (face.id === faceDto.id ? { ...face, person } : face)),
),
meta: { error: { toast: 'errors.cant_apply_changes' } },
});