mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): re-assign person faces (2) (#4949)
* 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>
This commit is contained in:
parent
982183600d
commit
7702560b12
74 changed files with 4882 additions and 283 deletions
|
|
@ -560,7 +560,7 @@
|
|||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
|
||||
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,18 @@
|
|||
mdiCalendar,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
mdiPencil,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiImageOutline,
|
||||
mdiMapMarkerOutline,
|
||||
mdiInformationOutline,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiPencil,
|
||||
} from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import Map from '../shared-components/map/map.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import ChangeLocation from '../shared-components/change-location.svelte';
|
||||
|
|
@ -35,8 +37,21 @@
|
|||
export let albums: AlbumResponseDto[] = [];
|
||||
export let albumId: string | null = null;
|
||||
|
||||
let showAssetPath = false;
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let description: string;
|
||||
let showEditFaces = false;
|
||||
let previousId: string;
|
||||
|
||||
$: {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
}
|
||||
if (asset.id !== previousId) {
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
}
|
||||
}
|
||||
|
||||
$: isOwner = $page?.data?.user?.id === asset.ownerId;
|
||||
|
||||
|
|
@ -84,6 +99,14 @@
|
|||
return undefined;
|
||||
};
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
await api.assetApi.getAssetById({ id: asset.id }).then((res) => {
|
||||
people = res.data?.people || [];
|
||||
textarea.value = res.data?.exifInfo?.description || '';
|
||||
});
|
||||
showEditFaces = false;
|
||||
};
|
||||
|
||||
const autoGrowHeight = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
|
|
@ -106,7 +129,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
let showAssetPath = false;
|
||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||
|
||||
let isShowChangeDate = false;
|
||||
|
|
@ -139,7 +161,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<button
|
||||
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
|
|
@ -183,54 +205,71 @@
|
|||
<section class="px-4 py-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<h2>PEOPLE</h2>
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<div class="flex gap-2">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<CircleIconButton
|
||||
title="Show hidden people"
|
||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
padding="1"
|
||||
on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton
|
||||
title="Show hidden people"
|
||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
title="Edit people"
|
||||
icon={mdiPencil}
|
||||
padding="1"
|
||||
on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
|
||||
size="20"
|
||||
on:click={() => (showEditFaces = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each people as person (person.id)}
|
||||
<a
|
||||
href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
|
||||
class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
|
||||
on:click={() => dispatch('close-viewer')}
|
||||
{#each people as person, index (person.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex={index}
|
||||
on:focus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
{#if person.birthDate}
|
||||
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
|
||||
<p
|
||||
class="font-light"
|
||||
title={personBirthDate.toLocaleString(
|
||||
{
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
>
|
||||
Age {Math.floor(DateTime.fromISO(asset.fileCreatedAt).diff(personBirthDate, 'years').years)}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
|
||||
class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
|
||||
on:click={() => dispatch('close-viewer')}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
{#if person.birthDate}
|
||||
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
|
||||
<p
|
||||
class="font-light"
|
||||
title={personBirthDate.toLocaleString(
|
||||
{
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
>
|
||||
Age {Math.floor(DateTime.fromISO(asset.fileCreatedAt).diff(personBirthDate, 'years').years)}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -589,3 +628,13 @@
|
|||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if showEditFaces}
|
||||
<PersonSidePanel
|
||||
assetId={asset.id}
|
||||
on:close={() => {
|
||||
showEditFaces = false;
|
||||
}}
|
||||
on:refresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let element: HTMLDivElement | undefined = undefined;
|
||||
|
|
@ -20,6 +23,13 @@
|
|||
let copyImageToClipboard: (src: string) => Promise<Blob>;
|
||||
let canCopyImagesToClipboard: () => boolean;
|
||||
|
||||
$: if (imgElement) {
|
||||
createZoomImageWheel(imgElement, {
|
||||
maxZoom: 10,
|
||||
wheelZoomRatio: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
||||
// TODO: Move to regular import once the package correctly supports ESM.
|
||||
|
|
@ -29,6 +39,7 @@
|
|||
});
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
|
|
@ -105,16 +116,10 @@
|
|||
|
||||
if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed) {
|
||||
hasZoomed = true;
|
||||
|
||||
loadAssetData({ loadOriginal: true });
|
||||
}
|
||||
});
|
||||
|
||||
$: if (imgElement) {
|
||||
createZoomImageWheel(imgElement, {
|
||||
maxZoom: 10,
|
||||
wheelZoomRatio: 0.2,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeypress} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
|
||||
|
|
@ -129,12 +134,19 @@
|
|||
{:then}
|
||||
<div bind:this={imgElement} class="h-full w-full">
|
||||
<img
|
||||
bind:this={$photoViewer}
|
||||
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
||||
src={assetData}
|
||||
alt={asset.id}
|
||||
class="h-full w-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
|
||||
<div
|
||||
class="absolute border-solid border-white border-[3px] rounded-lg p-3"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
|
||||
{#if hidden}
|
||||
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
<Icon path={mdiEyeOffOutline} size="2em" class="text-{eyeColor}" />
|
||||
<Icon {title} path={mdiEyeOffOutline} size="2em" class="text-{eyeColor}" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
export let size: string | number = '1em';
|
||||
export let color = 'currentColor';
|
||||
export let path: string;
|
||||
export let title = '';
|
||||
export let title: string | null = null;
|
||||
export let desc = '';
|
||||
export let flipped = false;
|
||||
let className = '';
|
||||
|
|
|
|||
246
web/src/lib/components/faces-page/assign-face-side-panel.svelte
Normal file
246
web/src/lib/components/faces-page/assign-face-side-panel.svelte
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<script lang="ts">
|
||||
import { api, type AssetFaceResponseDto, type PersonResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Icon from '../elements/icon.svelte';
|
||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import { getPersonNameWithHiddenValue, searchNameLocal } from '$lib/utils/person';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
|
||||
export let peopleWithFaces: AssetFaceResponseDto[];
|
||||
export let allPeople: PersonResponseDto[];
|
||||
export let editedPersonIndex: number;
|
||||
|
||||
// loading spinners
|
||||
let isShowLoadingNewPerson = false;
|
||||
let isShowLoadingSearch = false;
|
||||
|
||||
// search people
|
||||
let searchedPeople: PersonResponseDto[] = [];
|
||||
let searchedPeopleCopy: PersonResponseDto[] = [];
|
||||
let searchWord: string;
|
||||
let searchFaces = false;
|
||||
let searchName = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const handleBackButton = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise<string | null> => {
|
||||
if ($photoViewer === null) {
|
||||
return null;
|
||||
}
|
||||
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2 } = face;
|
||||
|
||||
const coordinates = {
|
||||
x1: ($photoViewer.naturalWidth / face.imageWidth) * x1,
|
||||
x2: ($photoViewer.naturalWidth / face.imageWidth) * x2,
|
||||
y1: ($photoViewer.naturalHeight / face.imageHeight) * y1,
|
||||
y2: ($photoViewer.naturalHeight / face.imageHeight) * y2,
|
||||
};
|
||||
|
||||
const faceWidth = coordinates.x2 - coordinates.x1;
|
||||
const faceHeight = coordinates.y2 - coordinates.y1;
|
||||
|
||||
const faceImage = new Image();
|
||||
faceImage.src = $photoViewer.src;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
faceImage.onload = resolve;
|
||||
faceImage.onerror = () => resolve(null);
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = faceWidth;
|
||||
canvas.height = faceHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
||||
|
||||
return canvas.toDataURL();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePerson = async () => {
|
||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), 100);
|
||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
||||
|
||||
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
||||
|
||||
dispatch('createPerson', newFeaturePhoto);
|
||||
|
||||
clearTimeout(timeout);
|
||||
isShowLoadingNewPerson = false;
|
||||
dispatch('createPerson', newFeaturePhoto);
|
||||
};
|
||||
|
||||
const searchPeople = async () => {
|
||||
if ((searchedPeople.length < 20 && searchName.startsWith(searchWord)) || searchName === '') {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => (isShowLoadingSearch = true), 100);
|
||||
try {
|
||||
const { data } = await api.searchApi.searchPerson({ name: searchName });
|
||||
searchedPeople = data;
|
||||
searchedPeopleCopy = data;
|
||||
searchWord = searchName;
|
||||
} catch (error) {
|
||||
handleError(error, "Can't search people");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
isShowLoadingSearch = false;
|
||||
};
|
||||
|
||||
$: {
|
||||
searchedPeople = searchNameLocal(searchName, searchedPeopleCopy, 10);
|
||||
}
|
||||
|
||||
const initInput = (element: HTMLInputElement) => {
|
||||
element.focus();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
transition:fly={{ x: 360, duration: 100, easing: linear }}
|
||||
class="absolute top-0 z-[2001] 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">
|
||||
{#if !searchFaces}
|
||||
<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">Select face</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
title="Search existing person"
|
||||
on:click={() => {
|
||||
searchFaces = true;
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiMagnify} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
{#if !isShowLoadingNewPerson}
|
||||
<button
|
||||
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
on:click={handleCreatePerson}
|
||||
title="Create new person"
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiPlus} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
<div class="w-full flex">
|
||||
<input
|
||||
class="w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg"
|
||||
type="text"
|
||||
placeholder="Name or nickname"
|
||||
bind:value={searchName}
|
||||
on:input={searchPeople}
|
||||
use:initInput
|
||||
/>
|
||||
{#if isShowLoadingSearch}
|
||||
<div>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
on:click={() => (searchFaces = false)}
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiClose} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-4 py-4 text-sm">
|
||||
<h2 class="mb-8 mt-4 uppercase">All people</h2>
|
||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||
{#if searchName == ''}
|
||||
{#each allPeople as person (person.id)}
|
||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
|
||||
{person.name}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each searchedPeople as person (person.id)}
|
||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={person.isHidden}
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -12,23 +12,18 @@
|
|||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { mdiCallMerge, mdiClose, mdiMagnify, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
|
||||
import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { searchNameLocal } from '$lib/utils/person';
|
||||
import PeopleList from './people-list.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let person: PersonResponseDto;
|
||||
let people: PersonResponseDto[] = [];
|
||||
let peopleCopy: PersonResponseDto[] = [];
|
||||
let selectedPeople: PersonResponseDto[] = [];
|
||||
let screenHeight: number;
|
||||
let isShowConfirmation = false;
|
||||
let name = '';
|
||||
let searchWord: string;
|
||||
let isSearchingPeople = false;
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
$: hasSelection = selectedPeople.length > 0;
|
||||
|
|
@ -39,44 +34,12 @@
|
|||
onMount(async () => {
|
||||
const { data } = await api.personApi.getAllPeople({ withHidden: false });
|
||||
people = data.people;
|
||||
peopleCopy = cloneDeep(people);
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
dispatch('go-back');
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
name = '';
|
||||
people = peopleCopy;
|
||||
};
|
||||
|
||||
const searchPeople = async (force: boolean) => {
|
||||
if (name === '') {
|
||||
people = peopleCopy;
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
if (people.length < 20 && name.startsWith(searchWord)) {
|
||||
people = searchNameLocal(name, peopleCopy, 10);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => (isSearchingPeople = true), 100);
|
||||
try {
|
||||
const { data } = await api.searchApi.searchPerson({ name });
|
||||
people = data;
|
||||
searchWord = name;
|
||||
} catch (error) {
|
||||
handleError(error, "Can't search people");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
isSearchingPeople = false;
|
||||
};
|
||||
|
||||
const handleSwapPeople = () => {
|
||||
[person, selectedPeople[0]] = [selectedPeople[0], person];
|
||||
$page.url.searchParams.set('action', 'merge');
|
||||
|
|
@ -113,7 +76,7 @@
|
|||
});
|
||||
dispatch('merge');
|
||||
} catch (error) {
|
||||
handleError(error, 'Cannot merge faces');
|
||||
handleError(error, 'Cannot merge people');
|
||||
} finally {
|
||||
isShowConfirmation = false;
|
||||
}
|
||||
|
|
@ -131,7 +94,7 @@
|
|||
{#if hasSelection}
|
||||
Selected {selectedPeople.length}
|
||||
{:else}
|
||||
Merge faces
|
||||
Merge people
|
||||
{/if}
|
||||
<div />
|
||||
</svelte:fragment>
|
||||
|
|
@ -151,7 +114,7 @@
|
|||
<section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
|
||||
<section id="merge-face-selector relative">
|
||||
<div class="mb-10 h-[200px] place-content-center place-items-center">
|
||||
<p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to merge</p>
|
||||
<p class="mb-4 text-center uppercase dark:text-white">Choose matching people to merge</p>
|
||||
|
||||
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
|
||||
{#each selectedPeople as person (person.id)}
|
||||
|
|
@ -178,57 +141,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-40 sm:w-48 md:w-96 h-14 rounded-lg bg-gray-100 p-2 dark:bg-gray-700 mb-8 gap-2 place-items-center"
|
||||
>
|
||||
<button on:click={() => searchPeople(true)}>
|
||||
<div class="w-fit">
|
||||
<Icon path={mdiMagnify} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
|
||||
type="text"
|
||||
placeholder="Search names"
|
||||
bind:value={name}
|
||||
on:input={() => searchPeople(false)}
|
||||
/>
|
||||
{#if name}
|
||||
<button on:click={resetSearch}>
|
||||
<Icon path={mdiClose} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isSearchingPeople}
|
||||
<div class="flex place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 pt-8 px-8 pb-10 dark:bg-immich-dark-gray"
|
||||
style:max-height={screenHeight - 250 - 250 + 'px'}
|
||||
>
|
||||
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
{#each unselectedPeople as person (person.id)}
|
||||
<FaceThumbnail {person} on:click={() => onSelect(person)} circle border selectable />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<PeopleList
|
||||
people={unselectedPeople}
|
||||
peopleCopy={unselectedPeople}
|
||||
unselectedPeople={selectedPeople}
|
||||
{screenHeight}
|
||||
on:select={({ detail }) => onSelect(detail)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#if isShowConfirmation}
|
||||
<ConfirmDialogue
|
||||
title="Merge faces"
|
||||
title="Merge people"
|
||||
confirmText="Merge"
|
||||
on:confirm={handleMerge}
|
||||
on:cancel={() => (isShowConfirmation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Are you sure you want merge these faces? <br />This action is <strong>irreversible</strong>.</p>
|
||||
</svelte:fragment>
|
||||
<p>Are you sure you want merge these people ?</p></svelte:fragment
|
||||
>
|
||||
</ConfirmDialogue>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
>
|
||||
<div class="relative flex items-center justify-between">
|
||||
<h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
Merge faces - {title}
|
||||
Merge People - {title}
|
||||
</h1>
|
||||
<div class="p-2">
|
||||
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex px-4 md:px-8 md:pt-4">
|
||||
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
|
||||
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
|
||||
</div>
|
||||
<div class="flex px-4 pt-2 md:px-8">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@
|
|||
export let person: PersonResponseDto;
|
||||
export let preload = false;
|
||||
|
||||
type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-faces' | 'hide-face';
|
||||
type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person';
|
||||
let dispatch = createEventDispatcher<{
|
||||
'change-name': void;
|
||||
'set-birth-date': void;
|
||||
'merge-faces': void;
|
||||
'hide-face': void;
|
||||
'merge-people': void;
|
||||
'hide-person': void;
|
||||
}>();
|
||||
|
||||
let showVerticalDots = false;
|
||||
|
|
@ -82,10 +82,10 @@
|
|||
{#if showContextMenu}
|
||||
<Portal target="body">
|
||||
<ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
|
||||
<MenuOption on:click={() => onMenuClick('hide-face')} text="Hide face" />
|
||||
<MenuOption on:click={() => onMenuClick('hide-person')} text="Hide Person" />
|
||||
<MenuOption on:click={() => onMenuClick('change-name')} text="Change name" />
|
||||
<MenuOption on:click={() => onMenuClick('set-birth-date')} text="Set date of birth" />
|
||||
<MenuOption on:click={() => onMenuClick('merge-faces')} text="Merge faces" />
|
||||
<MenuOption on:click={() => onMenuClick('merge-people')} text="Merge People" />
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
|
|
|||
106
web/src/lib/components/faces-page/people-list.svelte
Normal file
106
web/src/lib/components/faces-page/people-list.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import { api, type PersonResponseDto } from '@api';
|
||||
import FaceThumbnail from './face-thumbnail.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from '../elements/icon.svelte';
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { searchNameLocal } from '$lib/utils/person';
|
||||
|
||||
export let screenHeight: number;
|
||||
export let people: PersonResponseDto[];
|
||||
export let peopleCopy: PersonResponseDto[];
|
||||
export let unselectedPeople: PersonResponseDto[];
|
||||
|
||||
let name = '';
|
||||
let searchWord: string;
|
||||
let isSearchingPeople = false;
|
||||
|
||||
let dispatch = createEventDispatcher<{
|
||||
select: PersonResponseDto;
|
||||
}>();
|
||||
|
||||
const resetSearch = () => {
|
||||
name = '';
|
||||
people = peopleCopy;
|
||||
};
|
||||
|
||||
$: {
|
||||
people = peopleCopy.filter(
|
||||
(person) => !unselectedPeople.some((unselectedPerson) => unselectedPerson.id === person.id),
|
||||
);
|
||||
people = searchNameLocal(name, people, 10);
|
||||
}
|
||||
|
||||
const searchPeople = async (force: boolean) => {
|
||||
if (name === '') {
|
||||
people = peopleCopy;
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
if (people.length < 20 && name.startsWith(searchWord)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => (isSearchingPeople = true), 100);
|
||||
try {
|
||||
const { data } = await api.searchApi.searchPerson({ name });
|
||||
people = data;
|
||||
searchWord = name;
|
||||
} catch (error) {
|
||||
handleError(error, "Can't search people");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
isSearchingPeople = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex w-40 sm:w-48 md:w-96 h-14 rounded-lg bg-gray-100 p-2 dark:bg-gray-700 mb-8 gap-2 place-items-center">
|
||||
<button on:click={() => searchPeople(true)}>
|
||||
<div class="w-fit">
|
||||
<Icon path={mdiMagnify} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
|
||||
type="text"
|
||||
placeholder="Search names"
|
||||
bind:value={name}
|
||||
on:input={() => searchPeople(false)}
|
||||
/>
|
||||
{#if name}
|
||||
<button on:click={resetSearch}>
|
||||
<Icon path={mdiClose} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isSearchingPeople}
|
||||
<div class="flex place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
|
||||
style:max-height={screenHeight - 400 + 'px'}
|
||||
>
|
||||
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
{#each people as person (person.id)}
|
||||
<FaceThumbnail
|
||||
{person}
|
||||
on:click={() => {
|
||||
dispatch('select', person);
|
||||
}}
|
||||
circle
|
||||
border
|
||||
selectable
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
278
web/src/lib/components/faces-page/person-side-panel.svelte
Normal file
278
web/src/lib/components/faces-page/person-side-panel.svelte
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<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}
|
||||
|
|
@ -22,12 +22,12 @@
|
|||
>
|
||||
<div class="flex items-center">
|
||||
<CircleIconButton icon={mdiClose} on:click={() => dispatch('closeClick')} />
|
||||
<p class="ml-4 hidden sm:block">Show & hide faces</p>
|
||||
<p class="ml-4 hidden sm:block">Show & hide people</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center md:mr-8">
|
||||
<CircleIconButton
|
||||
title="Reset faces visibility"
|
||||
title="Reset people visibility"
|
||||
icon={mdiRestart}
|
||||
on:click={() => dispatch('reset-visibility')}
|
||||
/>
|
||||
|
|
|
|||
190
web/src/lib/components/faces-page/unmerge-face-selector.svelte
Normal file
190
web/src/lib/components/faces-page/unmerge-face-selector.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import FaceThumbnail from './face-thumbnail.svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { api, AssetFaceUpdateItem, type PersonResponseDto } from '@api';
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { mdiPlus, mdiMerge } from '@mdi/js';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { notificationController, NotificationType } from '../shared-components/notification/notification';
|
||||
import PeopleList from './people-list.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let assetIds: string[];
|
||||
export let personAssets: PersonResponseDto;
|
||||
|
||||
let people: PersonResponseDto[] = [];
|
||||
let selectedPerson: PersonResponseDto | null = null;
|
||||
let disableButtons = false;
|
||||
let showLoadingSpinnerCreate = false;
|
||||
let showLoadingSpinnerReassign = false;
|
||||
let hasSelection = false;
|
||||
let screenHeight: number;
|
||||
|
||||
$: unselectedPeople = selectedPerson
|
||||
? people.filter((person) => selectedPerson && person.id !== selectedPerson.id && personAssets.id !== person.id)
|
||||
: people;
|
||||
|
||||
let dispatch = createEventDispatcher();
|
||||
|
||||
const selectedPeople: AssetFaceUpdateItem[] = [];
|
||||
|
||||
for (const assetId of assetIds) {
|
||||
selectedPeople.push({ assetId, personId: personAssets.id });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const { data } = await api.personApi.getAllPeople({ withHidden: false });
|
||||
people = data.people;
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const handleSelectedPerson = (person: PersonResponseDto) => {
|
||||
if (selectedPerson && selectedPerson.id === person.id) {
|
||||
handleRemoveSelectedPerson();
|
||||
return;
|
||||
}
|
||||
selectedPerson = person;
|
||||
hasSelection = true;
|
||||
};
|
||||
|
||||
const handleRemoveSelectedPerson = () => {
|
||||
selectedPerson = null;
|
||||
hasSelection = false;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const timeout = setTimeout(() => (showLoadingSpinnerCreate = true), 100);
|
||||
|
||||
try {
|
||||
disableButtons = true;
|
||||
const { data } = await api.personApi.createPerson();
|
||||
await api.personApi.reassignFaces({
|
||||
id: data.id,
|
||||
assetFaceUpdateDto: { data: selectedPeople },
|
||||
});
|
||||
|
||||
notificationController.show({
|
||||
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to a new person`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to reassign assets to a new person');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
showLoadingSpinnerCreate = false;
|
||||
dispatch('confirm');
|
||||
};
|
||||
|
||||
const handleReassign = async () => {
|
||||
const timeout = setTimeout(() => (showLoadingSpinnerReassign = true), 100);
|
||||
try {
|
||||
disableButtons = true;
|
||||
if (selectedPerson) {
|
||||
await api.personApi.reassignFaces({
|
||||
id: selectedPerson.id,
|
||||
assetFaceUpdateDto: { data: selectedPeople },
|
||||
});
|
||||
notificationController.show({
|
||||
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to ${
|
||||
selectedPerson.name || 'an existing person'
|
||||
}`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, `Unable to reassign assets to ${selectedPerson?.name || 'an existing person'}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
showLoadingSpinnerReassign = false;
|
||||
dispatch('confirm');
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={screenHeight} />
|
||||
|
||||
<section
|
||||
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
|
||||
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
|
||||
>
|
||||
<ControlAppBar on:close-button-click={onClose}>
|
||||
<svelte:fragment slot="leading">
|
||||
<slot name="header" />
|
||||
<div />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="trailing">
|
||||
<div class="flex gap-4">
|
||||
<Button
|
||||
title={'Assign selected assets to a new person'}
|
||||
size={'sm'}
|
||||
disabled={disableButtons || hasSelection}
|
||||
on:click={() => {
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
{#if !showLoadingSpinnerCreate}
|
||||
<Icon path={mdiPlus} size={18} />
|
||||
{:else}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
<span class="ml-2"> Create new Person</span></Button
|
||||
>
|
||||
<Button
|
||||
size={'sm'}
|
||||
title={'Assign selected assets to an existing person'}
|
||||
disabled={disableButtons || !hasSelection}
|
||||
on:click={() => {
|
||||
handleReassign();
|
||||
}}
|
||||
>
|
||||
{#if !showLoadingSpinnerReassign}
|
||||
<div>
|
||||
<Icon path={mdiMerge} size={18} class="rotate-180" />
|
||||
</div>
|
||||
{:else}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
<span class="ml-2"> Reassign</span></Button
|
||||
>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
<slot name="merge" />
|
||||
<section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
|
||||
<section id="merge-face-selector relative">
|
||||
{#if selectedPerson !== null}
|
||||
<div class="mb-10 h-[200px] place-content-center place-items-center">
|
||||
<p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to re assign</p>
|
||||
|
||||
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
|
||||
<FaceThumbnail
|
||||
person={selectedPerson}
|
||||
border
|
||||
circle
|
||||
selectable
|
||||
thumbnailSize={180}
|
||||
on:click={handleRemoveSelectedPerson}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<PeopleList
|
||||
people={unselectedPeople}
|
||||
peopleCopy={unselectedPeople}
|
||||
unselectedPeople={selectedPerson ? [selectedPerson, personAssets] : [personAssets]}
|
||||
{screenHeight}
|
||||
on:select={({ detail }) => handleSelectedPerson(detail)}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
|
@ -58,6 +58,8 @@ interface TrashAsset {
|
|||
value: string;
|
||||
}
|
||||
|
||||
export const photoViewer = writable<HTMLImageElement | null>(null);
|
||||
|
||||
type PendingChange = AddAsset | DeleteAsset | TrashAsset;
|
||||
|
||||
export class AssetStore {
|
||||
|
|
|
|||
12
web/src/lib/stores/people.store.ts
Normal file
12
web/src/lib/stores/people.store.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Faces {
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
}
|
||||
|
||||
export const boundingBoxesArray = writable<Faces[]>([]);
|
||||
71
web/src/lib/utils/people-utils.ts
Normal file
71
web/src/lib/utils/people-utils.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let width = img.height * ratio;
|
||||
let height = img.height;
|
||||
if (width > img.width) {
|
||||
width = img.width;
|
||||
height = img.width / ratio;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export interface boundingBox {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
): boundingBox[] => {
|
||||
const boxes: boundingBox[] = [];
|
||||
|
||||
if (photoViewer === null) {
|
||||
return boxes;
|
||||
}
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
const clientWidth = photoViewer.clientWidth;
|
||||
|
||||
const { width, height } = getContainedSize(photoViewer);
|
||||
|
||||
for (const face of faces) {
|
||||
/*
|
||||
*
|
||||
* Create the coordinates of the box based on the displayed image.
|
||||
* The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer.
|
||||
*
|
||||
*/
|
||||
const coordinates = {
|
||||
x1:
|
||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
x2:
|
||||
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
y1:
|
||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
y2:
|
||||
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
};
|
||||
|
||||
boxes.push({
|
||||
top: Math.round(coordinates.y1),
|
||||
left: Math.round(coordinates.x1),
|
||||
width: Math.round(coordinates.x2 - coordinates.x1),
|
||||
height: Math.round(coordinates.y2 - coordinates.y1),
|
||||
});
|
||||
}
|
||||
return boxes;
|
||||
};
|
||||
|
|
@ -30,3 +30,7 @@ export const searchNameLocal = (
|
|||
})
|
||||
.slice(0, slice);
|
||||
};
|
||||
|
||||
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
|
||||
return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue