mirror of
https://github.com/immich-app/immich.git
synced 2026-02-04 19:12:11 -08:00
feat: unassign faces
This commit is contained in:
@@ -72,7 +72,7 @@
|
||||
// Get latest description from server
|
||||
if (newAsset.id && !isSharedLink()) {
|
||||
const data = await getAssetInfo({ id: asset.id });
|
||||
people = data?.people || [];
|
||||
people = data?.people || undefined;
|
||||
|
||||
description = data.exifInfo?.description || '';
|
||||
}
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
})();
|
||||
|
||||
$: people = asset.people || [];
|
||||
$: people = asset?.people || undefined;
|
||||
$: showingHiddenPeople = false;
|
||||
|
||||
onMount(() => {
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
await getAssetInfo({ id: asset.id }).then((data) => {
|
||||
people = data?.people || [];
|
||||
people = data?.people || undefined;
|
||||
textArea.value = data?.exifInfo?.description || '';
|
||||
});
|
||||
showEditFaces = false;
|
||||
@@ -213,12 +213,12 @@
|
||||
<p class="px-4 break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
|
||||
{/if}
|
||||
|
||||
{#if !isSharedLink() && people.length > 0}
|
||||
{#if !isSharedLink() && people?.numberOfFaces && people?.numberOfFaces > 0}
|
||||
<section class="px-4 py-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<h2>PEOPLE</h2>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
{#if people.faces.some((person) => person.isHidden)}
|
||||
<CircleIconButton
|
||||
title="Show hidden people"
|
||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
@@ -239,16 +239,16 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each people as person, index (person.id)}
|
||||
{#each people.faces as person (person.id)}
|
||||
{#if showingHiddenPeople || !person.isHidden}
|
||||
<a
|
||||
class="w-[90px]"
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
|
||||
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
|
||||
: AppRoute.PHOTOS}"
|
||||
on:focus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:focus={() => ($boundingBoxesArray = person.faces)}
|
||||
on:blur={() => ($boundingBoxesArray = [])}
|
||||
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:mouseover={() => ($boundingBoxesArray = person.faces)}
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
@@ -614,9 +614,9 @@
|
||||
<PersonSidePanel
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
on:close={() => {
|
||||
onClose={() => {
|
||||
showEditFaces = false;
|
||||
}}
|
||||
on:refresh={handleRefreshPeople}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
|
||||
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque' | 'blue' | 'red' | 'green';
|
||||
|
||||
export let type: 'button' | 'submit' | 'reset' = 'button';
|
||||
export let icon: string;
|
||||
@@ -14,6 +14,7 @@
|
||||
* viewBox attribute for the SVG icon.
|
||||
*/
|
||||
export let viewBox: string | undefined = undefined;
|
||||
export let disableHover = false;
|
||||
|
||||
/**
|
||||
* Override the default styling of the button for specific use cases, such as the icon color.
|
||||
@@ -29,6 +30,9 @@
|
||||
gray: 'bg-[#d3d3d3] hover:bg-[#e2e7e9] text-immich-dark-gray hover:text-black',
|
||||
primary:
|
||||
'bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 hover:dark:bg-immich-dark-primary/80 text-white dark:text-immich-dark-gray',
|
||||
blue: 'bg-blue-700',
|
||||
red: 'bg-red-700',
|
||||
green: 'bg-green-700',
|
||||
};
|
||||
|
||||
$: colorClass = colorClasses[color];
|
||||
@@ -40,7 +44,9 @@
|
||||
{type}
|
||||
style:width={buttonSize ? buttonSize + 'px' : ''}
|
||||
style:height={buttonSize ? buttonSize + 'px' : ''}
|
||||
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
|
||||
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all {disableHover
|
||||
? ''
|
||||
: 'hover:dark:text-immich-dark-gray'} {className} {mobileClass}"
|
||||
on:click
|
||||
>
|
||||
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
||||
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let peopleWithFaces: AssetFaceResponseDto[];
|
||||
export let editedFace: AssetFaceResponseDto;
|
||||
export let allPeople: PersonResponseDto[];
|
||||
export let editedPerson: PersonResponseDto;
|
||||
export let assetType: AssetTypeEnum;
|
||||
export let assetId: string;
|
||||
export let editedPerson: PersonResponseDto | undefined = undefined;
|
||||
export let onClose = () => {};
|
||||
export let onCreatePerson: (featurePhoto: string | null) => void;
|
||||
export let onReassign: (person: PersonResponseDto) => void;
|
||||
|
||||
// loading spinners
|
||||
let isShowLoadingNewPerson = false;
|
||||
@@ -30,85 +32,20 @@
|
||||
|
||||
$: showPeople = searchName ? searchedPeople : allPeople.filter((person) => !person.isHidden);
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
createPerson: string | null;
|
||||
reassign: PersonResponseDto;
|
||||
}>();
|
||||
const handleBackButton = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise<string | null> => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
if (assetType === AssetTypeEnum.Image) {
|
||||
image = $photoViewer;
|
||||
} else if (assetType === AssetTypeEnum.Video) {
|
||||
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
||||
const img: HTMLImageElement = new Image();
|
||||
img.src = data;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.addEventListener('load', () => resolve());
|
||||
img.addEventListener('error', () => resolve());
|
||||
});
|
||||
|
||||
image = img;
|
||||
}
|
||||
if (image === null) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
boundingBoxX1: x1,
|
||||
boundingBoxX2: x2,
|
||||
boundingBoxY1: y1,
|
||||
boundingBoxY2: y2,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
} = face;
|
||||
|
||||
const coordinates = {
|
||||
x1: (image.naturalWidth / imageWidth) * x1,
|
||||
x2: (image.naturalWidth / imageWidth) * x2,
|
||||
y1: (image.naturalHeight / imageHeight) * y1,
|
||||
y2: (image.naturalHeight / imageHeight) * y2,
|
||||
};
|
||||
|
||||
const faceWidth = coordinates.x2 - coordinates.x1;
|
||||
const faceHeight = coordinates.y2 - coordinates.y1;
|
||||
|
||||
const faceImage = new Image();
|
||||
faceImage.src = image.src;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
faceImage.addEventListener('load', resolve);
|
||||
faceImage.addEventListener('error', () => resolve(null));
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = faceWidth;
|
||||
canvas.height = faceHeight;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
||||
|
||||
return canvas.toDataURL();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreatePerson = async () => {
|
||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
|
||||
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
||||
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetType, assetId);
|
||||
|
||||
dispatch('createPerson', newFeaturePhoto);
|
||||
onCreatePerson(newFeaturePhoto);
|
||||
|
||||
clearTimeout(timeout);
|
||||
isShowLoadingNewPerson = false;
|
||||
dispatch('createPerson', newFeaturePhoto);
|
||||
onCreatePerson(newFeaturePhoto);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -157,33 +94,42 @@
|
||||
{/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">
|
||||
{#each showPeople as person (person.id)}
|
||||
{#if person.id !== editedPerson.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={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>
|
||||
{#if showPeople.length > 0}
|
||||
<h2 class="mb-8 mt-4 uppercase">All people</h2>
|
||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||
{#each showPeople as person (person.id)}
|
||||
{#if person.id !== editedPerson?.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => onReassign(person)}>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={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}
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
|
||||
{person.name}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="grid place-items-center">
|
||||
<Icon path={mdiAccountOff} size="3.5em" />
|
||||
<p class="mt-5 font-medium">No faces found</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5,38 +5,51 @@
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
|
||||
import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
createPerson,
|
||||
getAllPeople,
|
||||
getFaces,
|
||||
reassignFacesById,
|
||||
unassignFace,
|
||||
type AssetFaceResponseDto,
|
||||
type PersonResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiFaceMan, mdiMinus, mdiRestart } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import type { FaceWithGeneretedThumbnail } from '$lib/utils/people-utils';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import AssignFaceSidePanel from '$lib/components/faces-page/assign-face-side-panel.svelte';
|
||||
import UnassignedFacesSidePanel from '$lib/components/faces-page/unassigned-faces-side-panel.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let assetId: string;
|
||||
export let assetType: AssetTypeEnum;
|
||||
export let onClose = () => {};
|
||||
export let onRefresh = () => {};
|
||||
|
||||
// keep track of the changes
|
||||
let peopleToCreate: string[] = [];
|
||||
let assetFaceGenerated: string[] = [];
|
||||
|
||||
// faces
|
||||
let allFaces: AssetFaceResponseDto[] = [];
|
||||
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
||||
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
||||
let selectedPersonToCreate: Record<string, string> = {};
|
||||
let selectedPersonToAdd: Record<string, FaceWithGeneretedThumbnail> = {};
|
||||
let selectedFaceToRemove: Record<string, AssetFaceResponseDto> = {};
|
||||
let editedPerson: PersonResponseDto;
|
||||
let editedFace: AssetFaceResponseDto;
|
||||
let unassignedFaces: FaceWithGeneretedThumbnail[] = [];
|
||||
|
||||
// loading spinners
|
||||
let isShowLoadingDone = false;
|
||||
@@ -44,6 +57,7 @@
|
||||
|
||||
// search people
|
||||
let showSelectedFaces = false;
|
||||
let showUnassignedFaces = false;
|
||||
let allPeople: PersonResponseDto[] = [];
|
||||
|
||||
// timers
|
||||
@@ -52,17 +66,26 @@
|
||||
|
||||
const thumbnailWidth = '90px';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
refresh: void;
|
||||
}>();
|
||||
const generatePeopleWithoutFaces = async () => {
|
||||
const peopleWithGeneratedImage = await Promise.all(
|
||||
allFaces.map(async (personWithFace) => {
|
||||
if (personWithFace.person === null) {
|
||||
const image = await zoomImageToBase64(personWithFace, assetType, assetId);
|
||||
return { ...personWithFace, customThumbnail: image };
|
||||
}
|
||||
}),
|
||||
);
|
||||
unassignedFaces = peopleWithGeneratedImage.filter((item): item is FaceWithGeneretedThumbnail => item !== undefined);
|
||||
};
|
||||
|
||||
async function loadPeople() {
|
||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||
try {
|
||||
const { people } = await getAllPeople({ withHidden: true });
|
||||
allPeople = people;
|
||||
peopleWithFaces = await getFaces({ id: assetId });
|
||||
allFaces = await getFaces({ id: assetId });
|
||||
peopleWithFaces = allFaces.filter((face) => face.person);
|
||||
await generatePeopleWithoutFaces();
|
||||
} catch (error) {
|
||||
handleError(error, "Can't get faces");
|
||||
} finally {
|
||||
@@ -73,15 +96,10 @@
|
||||
|
||||
const onPersonThumbnail = (personId: string) => {
|
||||
assetFaceGenerated.push(personId);
|
||||
if (
|
||||
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||
loaderLoadingDoneTimeout &&
|
||||
automaticRefreshTimeout &&
|
||||
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
|
||||
) {
|
||||
if (isEqual(assetFaceGenerated, peopleToCreate) && loaderLoadingDoneTimeout && automaticRefreshTimeout) {
|
||||
clearTimeout(loaderLoadingDoneTimeout);
|
||||
clearTimeout(automaticRefreshTimeout);
|
||||
dispatch('refresh');
|
||||
onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,10 +112,6 @@
|
||||
return b.every((valueB) => a.includes(valueB));
|
||||
};
|
||||
|
||||
const handleBackButton = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const handleReset = (id: string) => {
|
||||
if (selectedPersonToReassign[id]) {
|
||||
delete selectedPersonToReassign[id];
|
||||
@@ -113,9 +127,22 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFace = (face: AssetFaceResponseDto) => {
|
||||
selectedFaceToRemove[face.id] = face;
|
||||
};
|
||||
|
||||
const handleAbortRemove = (id: string) => {
|
||||
delete selectedFaceToRemove[id];
|
||||
selectedFaceToRemove = selectedFaceToRemove;
|
||||
};
|
||||
|
||||
const handleEditFaces = async () => {
|
||||
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
||||
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
|
||||
const numberOfChanges =
|
||||
Object.keys(selectedPersonToCreate).length +
|
||||
Object.keys(selectedPersonToReassign).length +
|
||||
Object.keys(selectedFaceToRemove).length +
|
||||
Object.keys(selectedPersonToAdd).length;
|
||||
|
||||
if (numberOfChanges > 0) {
|
||||
try {
|
||||
@@ -137,6 +164,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, face] of Object.entries(selectedPersonToAdd)) {
|
||||
if (face.person) {
|
||||
await reassignFacesById({
|
||||
id: face.person.id,
|
||||
faceDto: { id },
|
||||
});
|
||||
} else {
|
||||
const data = await createPerson({ personCreateDto: {} });
|
||||
peopleToCreate.push(data.id);
|
||||
await reassignFacesById({
|
||||
id: data.id,
|
||||
faceDto: { id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, _] of Object.entries(selectedFaceToRemove)) {
|
||||
await unassignFace({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
|
||||
type: NotificationType.Info,
|
||||
@@ -149,9 +198,9 @@
|
||||
isShowLoadingDone = false;
|
||||
if (peopleToCreate.length === 0) {
|
||||
clearTimeout(loaderLoadingDoneTimeout);
|
||||
dispatch('refresh');
|
||||
onRefresh();
|
||||
} else {
|
||||
automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15_000);
|
||||
automaticRefreshTimeout = setTimeout(() => onRefresh(), 15_000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,6 +225,27 @@
|
||||
showSelectedFaces = true;
|
||||
}
|
||||
};
|
||||
const handleCreateOrReassignFaceFromUnassignedFace = (face: FaceWithGeneretedThumbnail) => {
|
||||
selectedPersonToAdd[face.id] = face;
|
||||
selectedPersonToAdd = selectedPersonToAdd;
|
||||
showUnassignedFaces = false;
|
||||
};
|
||||
|
||||
const handleOpenAvailableFaces = () => {
|
||||
showUnassignedFaces = !showUnassignedFaces;
|
||||
};
|
||||
const handleRemoveAddedFace = (face: FaceWithGeneretedThumbnail) => {
|
||||
$boundingBoxesArray = [];
|
||||
delete selectedPersonToAdd[face.id];
|
||||
|
||||
// trigger reactivity
|
||||
selectedPersonToAdd = selectedPersonToAdd;
|
||||
};
|
||||
const handleRemoveAllFaces = () => {
|
||||
for (const face of peopleWithFaces) {
|
||||
selectedFaceToRemove[face.id] = face;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -184,129 +254,249 @@
|
||||
>
|
||||
<div class="flex place-items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={handleBackButton} />
|
||||
<CircleIconButton icon={mdiArrowLeftThin} title="Back" on:click={onClose} />
|
||||
<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>
|
||||
<div class="flex gap-2">
|
||||
{#if peopleWithFaces.length > Object.keys(selectedFaceToRemove).length}
|
||||
<button
|
||||
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||
on:click={handleRemoveAllFaces}
|
||||
title="Remove all faces"
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiClose} />
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#if (unassignedFaces.length > 0 && unassignedFaces.length > Object.keys(selectedPersonToAdd).length) || Object.keys(selectedFaceToRemove).length > 0}
|
||||
<button
|
||||
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||
on:click={handleOpenAvailableFaces}
|
||||
title="Faces available"
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiFaceMan} />
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
{: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">
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={selectedPersonToCreate[face.id]}
|
||||
altText={selectedPersonToCreate[face.id]}
|
||||
title={'New person'}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
/>
|
||||
{:else if selectedPersonToReassign[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
||||
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
||||
title={getPersonNameWithHiddenValue(
|
||||
selectedPersonToReassign[face.id].name,
|
||||
face.person?.isHidden,
|
||||
)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||
/>
|
||||
{:else}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(face.person.id)}
|
||||
altText={face.person.name || face.person.id}
|
||||
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={face.person.isHidden}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedPersonToCreate[face.id]}
|
||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||
{#if selectedPersonToReassign[face.id]?.id}
|
||||
{selectedPersonToReassign[face.id]?.name}
|
||||
{#if peopleWithFaces.length > 0}
|
||||
<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 && !selectedFaceToRemove[face.id]}
|
||||
<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">
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={selectedPersonToCreate[face.id]}
|
||||
altText={selectedPersonToCreate[face.id]}
|
||||
title={'New person'}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
/>
|
||||
{:else if selectedPersonToReassign[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
||||
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
||||
title={getPersonNameWithHiddenValue(
|
||||
selectedPersonToReassign[face.id].name,
|
||||
face.person?.isHidden,
|
||||
)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||
/>
|
||||
{:else}
|
||||
{face.person?.name}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(face.person.id)}
|
||||
altText={face.person.name || face.person.id}
|
||||
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={face.person.isHidden}
|
||||
/>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full">
|
||||
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
||||
<CircleIconButton
|
||||
color="primary"
|
||||
icon={mdiRestart}
|
||||
title="Reset"
|
||||
size="18"
|
||||
padding="1"
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
||||
on:click={() => handleReset(face.id)}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
color="primary"
|
||||
icon={mdiMinus}
|
||||
title="Select new face"
|
||||
size="18"
|
||||
padding="1"
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
||||
on:click={() => handleFacePicker(face)}
|
||||
/>
|
||||
{#if !selectedPersonToCreate[face.id]}
|
||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||
{#if selectedPersonToReassign[face.id]?.id}
|
||||
{selectedPersonToReassign[face.id]?.name}
|
||||
{:else}
|
||||
{face.person?.name}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id]}
|
||||
<div class="absolute -left-[8px] -bottom-[8px] h-[20px] w-[20px]">
|
||||
<CircleIconButton
|
||||
color="red"
|
||||
icon={mdiClose}
|
||||
title="Reset"
|
||||
size="20"
|
||||
buttonSize="20"
|
||||
padding="[1px]"
|
||||
disableHover
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||
on:click={() => handleRemoveFace(face)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
||||
<CircleIconButton
|
||||
color="blue"
|
||||
icon={mdiRestart}
|
||||
title="Reset"
|
||||
size="20"
|
||||
buttonSize="20"
|
||||
padding="[1px]"
|
||||
disableHover
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||
on:click={() => handleReset(face.id)}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
color="blue"
|
||||
icon={mdiMinus}
|
||||
title="Select new face"
|
||||
size="20"
|
||||
buttonSize="20"
|
||||
disableHover
|
||||
padding="[1px]"
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"
|
||||
on:click={() => handleFacePicker(face)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="grid place-items-center">
|
||||
<Icon path={mdiAccountOff} size="3.5em" />
|
||||
<p class="mt-5 font-medium">No visible faces</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-4 py-4 text-sm">
|
||||
{#if Object.keys(selectedPersonToAdd).length > 0}
|
||||
<div class="mt-8">
|
||||
<p>Faces to add</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{#each Object.entries(selectedPersonToAdd) as [_, face], index}
|
||||
{#if face}
|
||||
<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 = [face])}
|
||||
on:mouseover={() => ($boundingBoxesArray = [face])}
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={face.person ? getPeopleThumbnailUrl(face.person.id) : face.customThumbnail}
|
||||
altText={'New person'}
|
||||
title={'New person'}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
/>
|
||||
</div>
|
||||
{#if face.person?.name}
|
||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||
{face.person?.name}
|
||||
</p>{/if}
|
||||
|
||||
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||
<CircleIconButton
|
||||
color="red"
|
||||
icon={mdiMinus}
|
||||
title="Reset"
|
||||
size="20"
|
||||
buttonSize="20"
|
||||
padding="[1px]"
|
||||
disableHover
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||
on:click={() => handleRemoveAddedFace(face)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if showSelectedFaces}
|
||||
<AssignFaceSidePanel
|
||||
{peopleWithFaces}
|
||||
{editedFace}
|
||||
{allPeople}
|
||||
{editedPerson}
|
||||
{assetType}
|
||||
{assetId}
|
||||
on:close={() => (showSelectedFaces = false)}
|
||||
on:createPerson={(event) => handleCreatePerson(event.detail)}
|
||||
on:reassign={(event) => handleReassignFace(event.detail)}
|
||||
onClose={() => (showSelectedFaces = false)}
|
||||
onCreatePerson={handleCreatePerson}
|
||||
onReassign={handleReassignFace}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showUnassignedFaces}
|
||||
<UnassignedFacesSidePanel
|
||||
{assetType}
|
||||
{assetId}
|
||||
{allPeople}
|
||||
{unassignedFaces}
|
||||
{selectedPersonToAdd}
|
||||
{selectedFaceToRemove}
|
||||
onResetFacesToBeRemoved={() => (selectedFaceToRemove = selectedFaceToRemove)}
|
||||
onClose={() => (showUnassignedFaces = false)}
|
||||
onCreatePerson={handleCreateOrReassignFaceFromUnassignedFace}
|
||||
onReassign={handleCreateOrReassignFaceFromUnassignedFace}
|
||||
onAbortRemove={handleAbortRemove}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { linear } from 'svelte/easing';
|
||||
import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiMinus } from '@mdi/js';
|
||||
import type { FaceWithGeneretedThumbnail } from '$lib/utils/people-utils';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import type { AssetFaceResponseDto, AssetTypeEnum, PersonResponseDto } from '@immich/sdk';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import AssignFaceSidePanel from '$lib/components/faces-page/assign-face-side-panel.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
|
||||
export let unassignedFaces: FaceWithGeneretedThumbnail[];
|
||||
export let allPeople: PersonResponseDto[];
|
||||
export let selectedPersonToAdd: Record<string, FaceWithGeneretedThumbnail>;
|
||||
export let selectedFaceToRemove: Record<string, AssetFaceResponseDto>;
|
||||
export let assetType: AssetTypeEnum;
|
||||
export let assetId: string;
|
||||
export let onResetFacesToBeRemoved = () => {};
|
||||
export let onClose = () => {};
|
||||
export let onCreatePerson: (face: FaceWithGeneretedThumbnail) => void;
|
||||
export let onReassign: (face: FaceWithGeneretedThumbnail) => void;
|
||||
export let onAbortRemove: (id: string) => void;
|
||||
|
||||
let showSeletecFaces = false;
|
||||
let editedFace: FaceWithGeneretedThumbnail;
|
||||
|
||||
const handleSelectedFace = (face: FaceWithGeneretedThumbnail) => {
|
||||
editedFace = face;
|
||||
showSeletecFaces = true;
|
||||
};
|
||||
|
||||
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
||||
showSeletecFaces = false;
|
||||
if (newFeaturePhoto) {
|
||||
editedFace.customThumbnail = newFeaturePhoto;
|
||||
onCreatePerson(editedFace);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReassignFace = (person: PersonResponseDto | null) => {
|
||||
if (person) {
|
||||
showSeletecFaces = false;
|
||||
editedFace.person = person;
|
||||
onReassign(editedFace);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbortRemove = (id: string) => {
|
||||
delete selectedFaceToRemove[id];
|
||||
selectedFaceToRemove = selectedFaceToRemove;
|
||||
onAbortRemove(id);
|
||||
};
|
||||
|
||||
const handleRemoveAllFaces = () => {
|
||||
for (const [id, _] of Object.entries(selectedFaceToRemove)) {
|
||||
delete selectedFaceToRemove[id];
|
||||
}
|
||||
|
||||
// trigger reactivity
|
||||
selectedFaceToRemove = selectedFaceToRemove;
|
||||
onResetFacesToBeRemoved();
|
||||
};
|
||||
</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 bg-immich-bg 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={onClose}
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiArrowLeftThin} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Faces available</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if unassignedFaces.length > 0 && unassignedFaces.length > Object.keys(selectedPersonToAdd).length}
|
||||
<div class="px-4 py-4 text-sm">
|
||||
<p>Faces removed</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{#each unassignedFaces as face, index (face.id)}
|
||||
{#if !selectedPersonToAdd[face.id]}
|
||||
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||
<button
|
||||
tabindex={index}
|
||||
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
||||
on:focus={() => (face ? ($boundingBoxesArray = [face]) : '')}
|
||||
on:mouseover={() => (face ? ($boundingBoxesArray = [face]) : '')}
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={face.customThumbnail}
|
||||
title="Available face"
|
||||
altText="Available face"
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
/>
|
||||
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||
<CircleIconButton
|
||||
color="blue"
|
||||
icon={mdiMinus}
|
||||
title="Reset"
|
||||
size="20"
|
||||
buttonSize="20"
|
||||
padding="[1px]"
|
||||
disableHover
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||
on:click={() => handleSelectedFace(face)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="grid place-items-center">
|
||||
<Icon path={mdiAccountOff} size="3.5em" />
|
||||
<p class="mt-5 font-medium">No faces removed</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if Object.keys(selectedFaceToRemove).length > 0}
|
||||
<div class="px-4 py-4 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Faces to be removed</p>
|
||||
<button
|
||||
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||
on:click={handleRemoveAllFaces}
|
||||
title="Reset"
|
||||
>
|
||||
<div>
|
||||
<Icon path={mdiClose} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{#each Object.entries(selectedFaceToRemove) as [id, face], index}
|
||||
<div class="relative z-[20001] h-[115px] w-[95px]">
|
||||
<button
|
||||
tabindex={index}
|
||||
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
|
||||
on:focus={() => (face ? ($boundingBoxesArray = [face]) : '')}
|
||||
on:mouseover={() => (face ? ($boundingBoxesArray = [face]) : '')}
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={face.person ? getPeopleThumbnailUrl(face.person?.id) : ''}
|
||||
title="Available face"
|
||||
altText="Available face"
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
/>
|
||||
<div class="absolute -right-[8px] -top-[8px] h-[20px] w-[20px]">
|
||||
<CircleIconButton
|
||||
color="blue"
|
||||
icon={mdiClose}
|
||||
title="Reset"
|
||||
size="20"
|
||||
buttonSize="20"
|
||||
padding="[1px]"
|
||||
disableHover
|
||||
class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]"
|
||||
on:click={() => handleAbortRemove(id)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if showSeletecFaces}
|
||||
<AssignFaceSidePanel
|
||||
{assetType}
|
||||
{assetId}
|
||||
{editedFace}
|
||||
{allPeople}
|
||||
onClose={() => (showSeletecFaces = false)}
|
||||
onCreatePerson={handleCreatePerson}
|
||||
onReassign={handleReassignFace}
|
||||
/>
|
||||
{/if}
|
||||
@@ -6,10 +6,11 @@
|
||||
createPerson,
|
||||
getAllPeople,
|
||||
reassignFaces,
|
||||
unassignFaces,
|
||||
type AssetFaceUpdateItem,
|
||||
type PersonResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiMerge, mdiPlus } from '@mdi/js';
|
||||
import { mdiMerge, mdiPlus, mdiTagRemove } from '@mdi/js';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -28,6 +29,7 @@
|
||||
let disableButtons = false;
|
||||
let showLoadingSpinnerCreate = false;
|
||||
let showLoadingSpinnerReassign = false;
|
||||
let showLoadingSpinnerUnassign = false;
|
||||
let hasSelection = false;
|
||||
let screenHeight: number;
|
||||
|
||||
@@ -111,6 +113,24 @@
|
||||
showLoadingSpinnerReassign = false;
|
||||
dispatch('confirm');
|
||||
};
|
||||
|
||||
const handleUnassign = async () => {
|
||||
const timeout = setTimeout(() => (showLoadingSpinnerUnassign = true), 100);
|
||||
try {
|
||||
disableButtons = true;
|
||||
await unassignFaces({ assetFaceUpdateDto: { data: selectedPeople } });
|
||||
notificationController.show({
|
||||
message: `Un-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''}`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to unassign assets');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
showLoadingSpinnerCreate = false;
|
||||
dispatch('confirm');
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight={screenHeight} />
|
||||
@@ -126,6 +146,19 @@
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="trailing">
|
||||
<div class="flex gap-4">
|
||||
<Button
|
||||
title={'Unassign selected assets to a new person'}
|
||||
size={'sm'}
|
||||
disabled={disableButtons || hasSelection}
|
||||
on:click={handleUnassign}
|
||||
>
|
||||
{#if !showLoadingSpinnerUnassign}
|
||||
<Icon path={mdiTagRemove} size={18} />
|
||||
{:else}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
<span class="ml-2"> Unassign</span></Button
|
||||
>
|
||||
<Button
|
||||
title={'Assign selected assets to a new person'}
|
||||
size={'sm'}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { AssetFaceResponseDto } from '@immich/sdk';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
@@ -19,6 +20,10 @@ export interface boundingBox {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface FaceWithGeneretedThumbnail extends AssetFaceResponseDto {
|
||||
customThumbnail: string;
|
||||
}
|
||||
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { photoViewer } from '$lib/stores/assets.store';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { AssetTypeEnum, ThumbnailFormat, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const searchNameLocal = (
|
||||
name: string,
|
||||
@@ -28,3 +31,60 @@ export const searchNameLocal = (
|
||||
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
|
||||
return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
|
||||
};
|
||||
|
||||
export const zoomImageToBase64 = async (
|
||||
face: AssetFaceResponseDto,
|
||||
assetType: AssetTypeEnum,
|
||||
assetId: string,
|
||||
): Promise<string | null> => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
if (assetType === AssetTypeEnum.Image) {
|
||||
image = get(photoViewer);
|
||||
} else if (assetType === AssetTypeEnum.Video) {
|
||||
const data = getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp);
|
||||
const img: HTMLImageElement = new Image();
|
||||
img.src = data;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.addEventListener('load', () => resolve());
|
||||
img.addEventListener('error', () => resolve());
|
||||
});
|
||||
|
||||
image = img;
|
||||
}
|
||||
if (image === null) {
|
||||
return null;
|
||||
}
|
||||
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
|
||||
|
||||
const coordinates = {
|
||||
x1: (image.naturalWidth / imageWidth) * x1,
|
||||
x2: (image.naturalWidth / imageWidth) * x2,
|
||||
y1: (image.naturalHeight / imageHeight) * y1,
|
||||
y2: (image.naturalHeight / imageHeight) * y2,
|
||||
};
|
||||
|
||||
const faceWidth = coordinates.x2 - coordinates.x1;
|
||||
const faceHeight = coordinates.y2 - coordinates.y1;
|
||||
|
||||
const faceImage = new Image();
|
||||
faceImage.src = image.src;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
faceImage.addEventListener('load', resolve);
|
||||
faceImage.addEventListener('error', () => resolve(null));
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = faceWidth;
|
||||
canvas.height = faceHeight;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
|
||||
|
||||
return canvas.toDataURL();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export function getAltText(asset: AssetResponseDto) {
|
||||
altText += ` in ${asset.exifInfo.city}, ${asset.exifInfo.country}`;
|
||||
}
|
||||
|
||||
const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? [];
|
||||
const names = asset.people?.faces.filter((p) => p.name).map((p) => p.name) ?? [];
|
||||
if (names.length == 1) {
|
||||
altText += ` with ${names[0]}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user