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:
martin 2023-12-05 16:43:15 +01:00 committed by GitHub
parent 982183600d
commit 7702560b12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 4882 additions and 283 deletions

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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}

View file

@ -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 = '';

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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}

View 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>

View 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}

View file

@ -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')}
/>

View 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>

View file

@ -58,6 +58,8 @@ interface TrashAsset {
value: string;
}
export const photoViewer = writable<HTMLImageElement | null>(null);
type PendingChange = AddAsset | DeleteAsset | TrashAsset;
export class AssetStore {

View 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[]>([]);

View 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;
};

View file

@ -30,3 +30,7 @@ export const searchNameLocal = (
})
.slice(0, slice);
};
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
};