Compare commits

...

2 Commits

Author SHA1 Message Date
midzelis 7611422f34 feat(web): add interactive face bounding boxes to panorama viewer
Change-Id: Idde8affabcfaf2f1bde70fb84f1369ed6a6a6964
2026-04-16 14:37:25 +00:00
midzelis bb7aa27e91 refactor(web): replace face hover with overlay elements and migrate people store to manager
Replace listener-based mouse hit-testing with per-face overlay elements for face hover
detection, and migrate people store state to AssetViewerManager.

- Render invisible divs for each face as pointer-event hover targets
- Show white border + name tooltip on active hover
- Add face bounding box markers to photo sphere viewer (panorama support)
- Migrate boundingBoxesArray/showingHiddenPeople from people.store.ts to AssetViewerManager
- Delete people.store.ts (Faces interface moved to asset-viewer-manager)

Change-Id: Ied4c3894cc58874ed65725dcb8cc7ce86a6a6964
2026-04-16 14:33:58 +00:00
10 changed files with 254 additions and 116 deletions
@@ -10,7 +10,6 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils'; import { delay, getDimensions } from '$lib/utils/asset-utils';
@@ -58,7 +57,6 @@
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []); let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []); let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined); let timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
let dateTime = $derived( let dateTime = $derived(
timeZone && asset.exifInfo?.dateTimeOriginal timeZone && asset.exifInfo?.dateTimeOriginal
@@ -188,12 +186,12 @@
{#if people.some((person) => person.isHidden)} {#if people.some((person) => person.isHidden)}
<IconButton <IconButton
aria-label={$t('show_hidden_people')} aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye} icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium" size="medium"
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
onclick={() => (showingHiddenPeople = !showingHiddenPeople)} onclick={() => assetViewerManager.toggleHiddenPeople()}
/> />
{/if} {/if}
<IconButton <IconButton
@@ -222,15 +220,17 @@
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)} {#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden} {#if assetViewerManager.isShowingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))} {@const isHighlighted = people[index].faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a <a
class="group w-22 outline-none" class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })} href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)} onfocus={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onblur={() => ($boundingBoxesArray = [])} onblur={() => assetViewerManager.clearHighlightedFaces()}
onmouseover={() => ($boundingBoxesArray = people[index].faces)} onpointerenter={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])} onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
> >
<div class="relative"> <div class="relative">
<ImageThumbnail <ImageThumbnail
@@ -24,7 +24,7 @@
{#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])} {#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner /> <LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]} {:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} /> <PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} {asset} />
{:catch} {:catch}
{$t('errors.failed_to_load_asset')} {$t('errors.failed_to_load_asset')}
{/await} {/await}
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils'; import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import type { AssetResponseDto } from '@immich/sdk';
import { import {
EquirectangularAdapter, EquirectangularAdapter,
Viewer, Viewer,
@@ -14,7 +14,7 @@
type PluginConstructor, type PluginConstructor,
} from '@photo-sphere-viewer/core'; } from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css'; import '@photo-sphere-viewer/core/index.css';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin'; import { MarkersPlugin, events as markerEvents } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css'; import '@photo-sphere-viewer/markers-plugin/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin'; import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin'; import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
@@ -22,8 +22,18 @@
import { escape } from 'lodash-es'; import { escape } from 'lodash-es';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
const FACE_MARKER_PREFIX = 'face_';
const OCR_MARKER_PREFIX = 'box_';
// Transparent, invisible hit target for hoverable face bounding boxes
const FACE_BOX_DEFAULT_SVG_STYLE = {
fill: 'rgba(0, 0, 0, 0)',
stroke: 'rgba(0, 0, 0, 0)',
strokeWidth: '0',
};
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg' // Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
const FACE_BOX_SVG_STYLE = { const FACE_BOX_HIGHLIGHTED_SVG_STYLE = {
fill: 'rgba(0, 0, 0, 0)', fill: 'rgba(0, 0, 0, 0)',
stroke: '#ffffff', stroke: '#ffffff',
strokeWidth: '3px', strokeWidth: '3px',
@@ -44,68 +54,137 @@
type Props = { type Props = {
panorama: string | { source: string }; panorama: string | { source: string };
originalPanorama?: string | { source: string }; originalPanorama?: string | { source: string };
asset?: AssetResponseDto;
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean; navbar?: boolean;
}; };
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); let {
panorama,
originalPanorama,
asset,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
}: Props = $props();
let container: HTMLDivElement | undefined = $state(); let container: HTMLDivElement | undefined = $state();
let viewer: Viewer; let viewer: Viewer;
let viewerReady = $state(false);
let animationInProgress: { cancel: () => void } | undefined; let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = []; let isHighlightFromSphere = false;
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => { const faces = $derived.by(() => {
// Debounce; don't do anything when the data didn't actually change. const result: Faces[] = [];
if (faces === previousFaces) { for (const person of asset?.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) {
result.push(face);
}
}
return result;
});
const getTextureWidth = () => {
if (!viewer?.state.textureData) {
return 0;
}
return viewer.state.textureData.panoData.croppedWidth;
};
const facePolygonPixels = (face: Faces, textureWidth: number): [number, number][] => {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
const ratio = textureWidth / face.imageWidth;
return [
[x1 * ratio, y1 * ratio],
[x2 * ratio, y1 * ratio],
[x2 * ratio, y2 * ratio],
[x1 * ratio, y2 * ratio],
];
};
let activeFaceMarkerIds = new Set<string>();
// Add/remove face markers when the face set changes (does not touch styles)
$effect(() => {
const currentFaces = faces;
if (!viewerReady || !viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
return; return;
} }
previousFaces = faces; const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
const textureWidth = getTextureWidth();
const desiredIds = new Set(currentFaces.map((f) => `${FACE_MARKER_PREFIX}${f.id}`));
// Remove markers that are no longer in the face set
for (const id of activeFaceMarkerIds) {
if (!desiredIds.has(id)) {
markersPlugin.removeMarker(id);
}
}
// Add markers that are new
for (const face of currentFaces) {
const id = `${FACE_MARKER_PREFIX}${face.id}`;
if (!activeFaceMarkerIds.has(id)) {
markersPlugin.addMarker({
id,
polygonPixels: facePolygonPixels(face, textureWidth),
svgStyle: FACE_BOX_DEFAULT_SVG_STYLE,
});
}
}
activeFaceMarkerIds = desiredIds;
});
// Update highlight styles and animate (does not add/remove markers)
$effect(() => {
const highlightedFaces = assetViewerManager.highlightedFaces;
if (animationInProgress) { if (animationInProgress) {
animationInProgress.cancel(); animationInProgress.cancel();
animationInProgress = undefined; animationInProgress = undefined;
} }
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) { if (!viewerReady || !viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
return; return;
} }
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin); const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
const highlightedIds = new Set(highlightedFaces.map((f) => f.id));
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees. // Update styles on existing face markers
// This is what we want because the facial recognition is done on the image, not the sphere. for (const id of activeFaceMarkerIds) {
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth; const faceId = id.slice(FACE_MARKER_PREFIX.length);
markersPlugin.updateMarker({
markersPlugin.clearMarkers(); id,
for (const [index, face] of faces.entries()) { svgStyle: highlightedIds.has(faceId) ? FACE_BOX_HIGHLIGHTED_SVG_STYLE : FACE_BOX_DEFAULT_SVG_STYLE,
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
const ratio = currentTextureWidth / face.imageWidth;
// Pixel values are translated to spherical coordinates and only then added to the panorama;
// no need to recalculate when the texture image changes to the original size.
markersPlugin.addMarker({
id: `face_${index}`,
polygonPixels: [
[x1 * ratio, y1 * ratio],
[x2 * ratio, y1 * ratio],
[x2 * ratio, y2 * ratio],
[x1 * ratio, y2 * ratio],
],
svgStyle: FACE_BOX_SVG_STYLE,
}); });
} }
// Smoothly pan to the highlighted (hovered-over) face. // Only animate when the highlight came from outside the sphere (e.g. detail panel hover)
if (faces.length === 1) { if (isHighlightFromSphere) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0]; isHighlightFromSphere = false;
const ratio = currentTextureWidth / w; } else if (highlightedFaces.length === 1) {
const textureWidth = getTextureWidth();
const {
boundingBoxX1: x1,
boundingBoxY1: y1,
boundingBoxX2: x2,
boundingBoxY2: y2,
imageWidth: w,
} = highlightedFaces[0];
const ratio = textureWidth / w;
const x = ((x1 + x2) * ratio) / 2; const x = ((x1 + x2) * ratio) / 2;
const y = ((y1 + y2) * ratio) / 2; const y = ((y1 + y2) * ratio) / 2;
animationInProgress = viewer.animate({ animationInProgress = viewer.animate({
textureX: x, textureX: x,
textureY: y, textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75), zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, // duration in ms speed: 500,
}); });
} }
}); });
@@ -114,6 +193,14 @@
updateOcrBoxes(ocrManager.showOverlay, ocrManager.data); updateOcrBoxes(ocrManager.showOverlay, ocrManager.data);
}); });
const clearOcrMarkers = (markersPlugin: MarkersPlugin) => {
for (const marker of markersPlugin.getMarkers()) {
if (marker.id.startsWith(OCR_MARKER_PREFIX)) {
markersPlugin.removeMarker(marker.id);
}
}
};
/** Use updateOnly=true on zoom, pan, or resize. */ /** Use updateOnly=true on zoom, pan, or resize. */
const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => { const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => {
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) { if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
@@ -121,11 +208,11 @@
} }
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin); const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
if (!showOverlay) { if (!showOverlay) {
markersPlugin.clearMarkers(); clearOcrMarkers(markersPlugin);
return; return;
} }
if (!updateOnly) { if (!updateOnly) {
markersPlugin.clearMarkers(); clearOcrMarkers(markersPlugin);
} }
const boxes = getOcrBoundingBoxes(ocrData, { const boxes = getOcrBoundingBoxes(ocrData, {
@@ -227,19 +314,47 @@
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
} }
const onReadyHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false); const onReadyHandler = () => {
viewerReady = true;
updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false);
};
const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true); const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true);
viewer.addEventListener(events.ReadyEvent.type, onReadyHandler); viewer.addEventListener(events.ReadyEvent.type, onReadyHandler);
viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler); viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler);
viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler); viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler);
viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true }); viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true });
// Face marker hover events
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
const onEnterMarker = (event: markerEvents.EnterMarkerEvent) => {
if (!event.marker.id.startsWith(FACE_MARKER_PREFIX)) {
return;
}
const faceId = event.marker.id.slice(FACE_MARKER_PREFIX.length);
const face = faces.find((f) => f.id === faceId);
if (face) {
isHighlightFromSphere = true;
assetViewerManager.setHighlightedFaces([face]);
}
};
const onLeaveMarker = (event: markerEvents.LeaveMarkerEvent) => {
if (!event.marker.id.startsWith(FACE_MARKER_PREFIX)) {
return;
}
isHighlightFromSphere = true;
assetViewerManager.clearHighlightedFaces();
};
markersPlugin.addEventListener(markerEvents.EnterMarkerEvent.type, onEnterMarker);
markersPlugin.addEventListener(markerEvents.LeaveMarkerEvent.type, onLeaveMarker);
return () => { return () => {
viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler); viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler);
viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler); viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler);
viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler); viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler);
viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler); viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler);
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
markersPlugin.removeEventListener(markerEvents.EnterMarkerEvent.type, onEnterMarker);
markersPlugin.removeEventListener(markerEvents.LeaveMarkerEvent.type, onLeaveMarker);
}; };
}); });
@@ -247,7 +362,8 @@
if (viewer) { if (viewer) {
viewer.destroy(); viewer.destroy();
} }
boundingBoxesUnsubscribe(); assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
assetViewerManager.zoom = 1; assetViewerManager.zoom = 1;
}); });
</script> </script>
@@ -6,10 +6,9 @@
import Thumbhash from '$lib/components/Thumbhash.svelte'; import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
@@ -50,12 +49,13 @@
untrack(() => { untrack(() => {
assetViewerManager.resetZoomState(); assetViewerManager.resetZoomState();
visibleImageReady = false; visibleImageReady = false;
$boundingBoxesArray = []; assetViewerManager.clearHighlightedFaces();
}); });
}); });
onDestroy(() => { onDestroy(() => {
$boundingBoxesArray = []; assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
}); });
let containerWidth = $state(0); let containerWidth = $state(0);
@@ -74,15 +74,13 @@
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight }); return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
}); });
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize)); const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0); const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]); let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => { $effect(() => {
if (isHighlighting) { if (isHighlighting) {
visibleBoxes = highlightedBoxes; visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
} }
}); });
@@ -160,6 +158,9 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity // eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>(); const map = new Map<Faces, string>();
for (const person of asset.people ?? []) { for (const person of asset.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) { for (const face of person.faces ?? []) {
map.set(face, person.name); map.set(face, person.name);
} }
@@ -168,36 +169,31 @@
}); });
const faces = $derived(Array.from(faceToNameMap.keys())); const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived.by(() => {
const handleImageMouseMove = (event: MouseEvent) => { if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
$boundingBoxesArray = []; return [];
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return;
} }
const natural = getNaturalSize(assetViewerManager.imgRef); const knownBoxes = getBoundingBox(faces, overlaySize);
const scaled = scaleToFit(natural, container); const result = knownBoxes.map((box, index) => ({
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState; ...box,
face: faces[index],
name: faceToNameMap.get(faces[index]),
}));
const contentOffsetX = (container.width - scaled.width) / 2; if (assetViewerManager.highlightedFaces.length === 0) {
const contentOffsetY = (container.height - scaled.height) / 2; return result;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
} }
};
const handleImageMouseLeave = () => { const knownIds = new Set(faces.map((f) => f.id));
$boundingBoxesArray = []; const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
}; const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
for (let i = 0; i < unassignedBoxes.length; i++) {
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
}
return result;
});
</script> </script>
<AssetViewerEvents {onCopy} {onZoom} /> <AssetViewerEvents {onCopy} {onZoom} />
@@ -218,8 +214,6 @@
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
role="presentation" role="presentation"
ondblclick={onZoom} ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ zoomTarget: adaptiveImage }} use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))} {...useSwipe((event) => onSwipe?.(event))}
> >
@@ -261,22 +255,27 @@
</defs> </defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" /> <rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg> </svg>
{#each visibleBoxes as boundingbox, index (boundingbox.id)} </div>
<div {#each boundingBoxes as boundingbox (boundingbox.id)}
class="absolute border-solid border-white border-3 rounded-lg" {@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" <!-- svelte-ignore a11y_no_static_element_interactions -->
></div> <div
{#if faceToNameMap.get(visibleBoundingBoxes[index])} class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
{#if isActive && boundingbox.name}
<div <div
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg" aria-hidden="true"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left + class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
boundingbox.width}px; transform: translateX(-100%);" style="top: {boundingbox.height + 4}px; right: 0;"
> >
{faceToNameMap.get(visibleBoundingBoxes[index])} {boundingbox.name}
</div> </div>
{/if} {/if}
{/each} </div>
</div> {/each}
{#each ocrBoxes as ocrBox (ocrBox.id)} {#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} /> <OcrBoundingBox {ocrBox} />
@@ -26,6 +26,7 @@
<PhotoSphereViewer <PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }} panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }} originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
{asset}
plugins={[videoPlugin]} plugins={[videoPlugin]}
{adapter} {adapter}
navbar navbar
@@ -4,7 +4,6 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -239,15 +238,15 @@
{:else} {:else}
{#each peopleWithFaces as face, index (face.id)} {#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')} {@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)} {@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
<div class="relative h-29 w-24"> <div class="relative h-29 w-24">
<div <div
role="button" role="button"
tabindex={index} tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default" class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onmouseover={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])} onmouseleave={() => assetViewerManager.clearHighlightedFaces()}
> >
<div class="relative"> <div class="relative">
{#if selectedPersonToCreate[face.id]} {#if selectedPersonToCreate[face.id]}
@@ -8,6 +8,16 @@ import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import type { ZoomImageWheelState } from '@zoom-image/core'; import type { ZoomImageWheelState } from '@zoom-image/core';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
export interface Faces {
id: string;
imageHeight: number;
imageWidth: number;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
}
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false); const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false); const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
@@ -47,6 +57,8 @@ class AssetViewerManager extends BaseEventManager<Events> {
#isEditFacesPanelOpen = $state(false); #isEditFacesPanelOpen = $state(false);
#viewingAssetStoreState = $state<AssetResponseDto>(); #viewingAssetStoreState = $state<AssetResponseDto>();
#viewState = $state<boolean>(false); #viewState = $state<boolean>(false);
#highlightedFaces = $state<Faces[]>([]);
#showingHiddenPeople = $state(false);
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>(); gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
get asset() { get asset() {
@@ -205,6 +217,30 @@ class AssetViewerManager extends BaseEventManager<Events> {
this.closeEditFacesPanel(); this.closeEditFacesPanel();
} }
get highlightedFaces() {
return this.#highlightedFaces;
}
setHighlightedFaces(faces: Faces[]) {
this.#highlightedFaces = faces;
}
clearHighlightedFaces() {
this.#highlightedFaces = [];
}
get isShowingHiddenPeople() {
return this.#showingHiddenPeople;
}
toggleHiddenPeople() {
this.#showingHiddenPeople = !this.#showingHiddenPeople;
}
hideHiddenPeople() {
this.#showingHiddenPeople = false;
}
setAsset(asset: AssetResponseDto) { setAsset(asset: AssetResponseDto) {
this.#viewingAssetStoreState = asset; this.#viewingAssetStoreState = asset;
this.#viewState = true; this.#viewState = true;
-13
View File
@@ -1,13 +0,0 @@
import { writable } from 'svelte/store';
export interface Faces {
id: string;
imageHeight: number;
imageWidth: number;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
}
export const boundingBoxesArray = writable<Faces[]>([]);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Faces } from '$lib/stores/people.store'; import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import type { Size } from '$lib/utils/container-utils'; import type { Size } from '$lib/utils/container-utils';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Faces } from '$lib/stores/people.store'; import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils'; import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk'; import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';