7702560b12
* feat: unassign person faces * multiple improvements * chore: regenerate api * feat: improve face interactions in photos * fix: tests * fix: tests * optimize * fix: wrong assignment on complex-multiple re-assignments * fix: thumbnails with large photos * fix: complex reassign * fix: don't send people with faces * fix: person thumbnail generation * chore: regenerate api * add tess * feat: face box even when zoomed * fix: change feature photo * feat: make the blue icon hoverable * chore: regenerate api * feat: use websocket * fix: loading spinner when clicking on the done button * fix: use the svelte way * fix: tests * simplify * fix: unused vars * fix: remove unused code * fix: add migration * chore: regenerate api * ci: add unit tests * chore: regenerate api * feat: if a new person is created for a face and the server takes more than 15 seconds to generate the person thumbnail, don't wait for it * reorganize * chore: regenerate api * feat: global edit * pr feedback * pr feedback * simplify * revert test * fix: face generation * fix: tests * fix: face generation * fix merge * feat: search names in unmerge face selector modal * fix: merge face selector * simplify feature photo generation * fix: change endpoint * pr feedback * chore: fix merge * chore: fix merge * fix: tests * fix: edit & hide buttons * fix: tests * feat: show if person is hidden * feat: rename face to person * feat: split in new panel * copy-paste-error * pr feedback * fix: feature photo * do not leak faces * fix: unmerge modal * fix: merge modal event * feat(server): remove duplicates * fix: title for image thumbnails * fix: disable side panel when there's no face until next PR --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
279 lines
10 KiB
Svelte
279 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { fly } from 'svelte/transition';
|
|
import { linear } from 'svelte/easing';
|
|
import { api, type PersonResponseDto, AssetFaceResponseDto } from '@api';
|
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
|
import { handleError } from '$lib/utils/handle-error';
|
|
import { createEventDispatcher, onMount } from 'svelte';
|
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
|
import { mdiArrowLeftThin, mdiRestart } from '@mdi/js';
|
|
import Icon from '../elements/icon.svelte';
|
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
|
import { websocketStore } from '$lib/stores/websocket';
|
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
|
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
|
|
|
export let assetId: string;
|
|
|
|
// keep track of the changes
|
|
let numberOfPersonToCreate: string[] = [];
|
|
let numberOfAssetFaceGenerated: string[] = [];
|
|
|
|
// faces
|
|
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
|
let selectedPersonToReassign: (PersonResponseDto | null)[];
|
|
let selectedPersonToCreate: (string | null)[];
|
|
let editedPersonIndex: number;
|
|
|
|
// loading spinners
|
|
let isShowLoadingDone = false;
|
|
let isShowLoadingPeople = false;
|
|
|
|
// search people
|
|
let showSeletecFaces = false;
|
|
let allPeople: PersonResponseDto[] = [];
|
|
|
|
// timers
|
|
let loaderLoadingDoneTimeout: NodeJS.Timeout;
|
|
let automaticRefreshTimeout: NodeJS.Timeout;
|
|
|
|
const { onPersonThumbnail } = websocketStore;
|
|
const dispatch = createEventDispatcher();
|
|
|
|
// Reset value
|
|
$onPersonThumbnail = '';
|
|
|
|
$: {
|
|
if ($onPersonThumbnail) {
|
|
numberOfAssetFaceGenerated.push($onPersonThumbnail);
|
|
if (
|
|
isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
|
|
loaderLoadingDoneTimeout &&
|
|
automaticRefreshTimeout &&
|
|
selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
|
|
) {
|
|
clearTimeout(loaderLoadingDoneTimeout);
|
|
clearTimeout(automaticRefreshTimeout);
|
|
dispatch('refresh');
|
|
}
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
const timeout = setTimeout(() => (isShowLoadingPeople = true), 100);
|
|
try {
|
|
const { data } = await api.personApi.getAllPeople({ withHidden: true });
|
|
allPeople = data.people;
|
|
const result = await api.faceApi.getFaces({ id: assetId });
|
|
peopleWithFaces = result.data;
|
|
selectedPersonToCreate = new Array<string | null>(peopleWithFaces.length);
|
|
selectedPersonToReassign = new Array<PersonResponseDto | null>(peopleWithFaces.length);
|
|
} catch (error) {
|
|
handleError(error, "Can't get faces");
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
isShowLoadingPeople = false;
|
|
});
|
|
|
|
const isEqual = (a: string[], b: string[]): boolean => {
|
|
return b.every((valueB) => a.includes(valueB));
|
|
};
|
|
|
|
const handleBackButton = () => {
|
|
dispatch('close');
|
|
};
|
|
|
|
const handleReset = (index: number) => {
|
|
if (selectedPersonToReassign[index]) {
|
|
selectedPersonToReassign[index] = null;
|
|
}
|
|
if (selectedPersonToCreate[index]) {
|
|
selectedPersonToCreate[index] = null;
|
|
}
|
|
};
|
|
|
|
const handleEditFaces = async () => {
|
|
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), 100);
|
|
const numberOfChanges =
|
|
selectedPersonToCreate.filter((person) => person !== null).length +
|
|
selectedPersonToReassign.filter((person) => person !== null).length;
|
|
if (numberOfChanges > 0) {
|
|
try {
|
|
for (let i = 0; i < peopleWithFaces.length; i++) {
|
|
const personId = selectedPersonToReassign[i]?.id;
|
|
|
|
if (personId) {
|
|
await api.faceApi.reassignFacesById({
|
|
id: personId,
|
|
faceDto: { id: peopleWithFaces[i].id },
|
|
});
|
|
} else if (selectedPersonToCreate[i]) {
|
|
const { data } = await api.personApi.createPerson();
|
|
numberOfPersonToCreate.push(data.id);
|
|
await api.faceApi.reassignFacesById({
|
|
id: data.id,
|
|
faceDto: { id: peopleWithFaces[i].id },
|
|
});
|
|
}
|
|
}
|
|
|
|
notificationController.show({
|
|
message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
|
|
type: NotificationType.Info,
|
|
});
|
|
} catch (error) {
|
|
handleError(error, "Can't apply changes");
|
|
}
|
|
}
|
|
|
|
isShowLoadingDone = false;
|
|
if (numberOfPersonToCreate.length === 0) {
|
|
clearTimeout(loaderLoadingDoneTimeout);
|
|
dispatch('refresh');
|
|
} else {
|
|
automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15000);
|
|
}
|
|
};
|
|
|
|
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
|
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
|
if (newFeaturePhoto && personToUpdate) {
|
|
selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
|
|
}
|
|
showSeletecFaces = false;
|
|
};
|
|
|
|
const handleReassignFace = (person: PersonResponseDto | null) => {
|
|
if (person) {
|
|
selectedPersonToReassign[editedPersonIndex] = person;
|
|
showSeletecFaces = false;
|
|
}
|
|
};
|
|
|
|
const handlePersonPicker = async (index: number) => {
|
|
editedPersonIndex = index;
|
|
showSeletecFaces = true;
|
|
};
|
|
</script>
|
|
|
|
<section
|
|
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
|
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 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
|
|
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={handleBackButton}
|
|
>
|
|
<div>
|
|
<Icon path={mdiArrowLeftThin} size="24" />
|
|
</div>
|
|
</button>
|
|
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
|
|
</div>
|
|
{#if !isShowLoadingDone}
|
|
<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>
|
|
|
|
<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}
|
|
<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">
|
|
<ImageThumbnail
|
|
curve
|
|
shadow
|
|
url={selectedPersonToCreate[index] ||
|
|
api.getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
|
|
altText={selectedPersonToReassign[index]
|
|
? selectedPersonToReassign[index]?.name || ''
|
|
: selectedPersonToCreate[index]
|
|
? 'new person'
|
|
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
|
title={selectedPersonToReassign[index]
|
|
? selectedPersonToReassign[index]?.name || ''
|
|
: selectedPersonToCreate[index]
|
|
? 'new person'
|
|
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
|
widthStyle="90px"
|
|
heightStyle="90px"
|
|
thumbhash={null}
|
|
hidden={selectedPersonToReassign[index]
|
|
? selectedPersonToReassign[index]?.isHidden
|
|
: selectedPersonToCreate[index]
|
|
? false
|
|
: face.person?.isHidden}
|
|
/>
|
|
</div>
|
|
{#if !selectedPersonToCreate[index]}
|
|
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
|
{#if selectedPersonToReassign[index]?.id}
|
|
{selectedPersonToReassign[index]?.name}
|
|
{:else}
|
|
{face.person?.name}
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
|
|
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
|
|
{#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
|
|
<button on:click={() => handleReset(index)} class="flex h-full w-full">
|
|
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
|
<div>
|
|
<Icon path={mdiRestart} size={18} />
|
|
</div>
|
|
</div>
|
|
</button>
|
|
{:else}
|
|
<button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
|
|
<div
|
|
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
|
|
/>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{#if showSeletecFaces}
|
|
<AssignFaceSidePanel
|
|
{peopleWithFaces}
|
|
{allPeople}
|
|
{editedPersonIndex}
|
|
on:close={() => (showSeletecFaces = false)}
|
|
on:createPerson={(event) => handleCreatePerson(event.detail)}
|
|
on:reassign={(event) => handleReassignFace(event.detail)}
|
|
/>
|
|
{/if}
|