mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 04:57:05 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7611422f34 | |||
| bb7aa27e91 |
@@ -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;
|
||||||
|
|||||||
@@ -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,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,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';
|
||||||
|
|||||||
Reference in New Issue
Block a user