mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
5e9bda7fab
chore: tailwind cannonical classes
276 lines
9.6 KiB
Svelte
276 lines
9.6 KiB
Svelte
<script lang="ts">
|
|
import { shortcuts } from '$lib/actions/shortcut';
|
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
|
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
|
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
|
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
|
|
import {
|
|
EquirectangularAdapter,
|
|
Viewer,
|
|
events,
|
|
type AdapterConstructor,
|
|
type PluginConstructor,
|
|
} from '@photo-sphere-viewer/core';
|
|
import '@photo-sphere-viewer/core/index.css';
|
|
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
|
import '@photo-sphere-viewer/markers-plugin/index.css';
|
|
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
|
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
|
|
import '@photo-sphere-viewer/settings-plugin/index.css';
|
|
import { escape } from 'lodash-es';
|
|
import { onDestroy, onMount } from 'svelte';
|
|
|
|
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
|
|
const FACE_BOX_SVG_STYLE = {
|
|
fill: 'rgba(0, 0, 0, 0)',
|
|
stroke: '#ffffff',
|
|
strokeWidth: '3px',
|
|
strokeLinejoin: 'round',
|
|
};
|
|
|
|
// Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3'
|
|
const OCR_BOX_SVG_STYLE = {
|
|
fill: 'var(--color-blue-500)',
|
|
fillOpacity: '0.1',
|
|
stroke: 'var(--color-blue-500)',
|
|
strokeWidth: '2px',
|
|
};
|
|
|
|
const OCR_TOOLTIP_HTML_CLASS =
|
|
'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text';
|
|
|
|
type Props = {
|
|
panorama: string | { source: string };
|
|
originalPanorama?: string | { source: string };
|
|
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
|
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
|
navbar?: boolean;
|
|
};
|
|
|
|
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
|
|
|
let container: HTMLDivElement | undefined = $state();
|
|
let viewer: Viewer;
|
|
|
|
let animationInProgress: { cancel: () => void } | undefined;
|
|
|
|
$effect(() => {
|
|
const faces: Faces[] = assetViewerManager.highlightedFaces;
|
|
|
|
if (animationInProgress) {
|
|
animationInProgress.cancel();
|
|
animationInProgress = undefined;
|
|
}
|
|
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
|
|
return;
|
|
}
|
|
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
|
|
|
|
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
|
|
// This is what we want because the facial recognition is done on the image, not the sphere.
|
|
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
|
|
|
|
markersPlugin.clearMarkers();
|
|
for (const [index, face] of faces.entries()) {
|
|
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.
|
|
if (faces.length === 1) {
|
|
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
|
|
const ratio = currentTextureWidth / w;
|
|
const x = ((x1 + x2) * ratio) / 2;
|
|
const y = ((y1 + y2) * ratio) / 2;
|
|
animationInProgress = viewer.animate({
|
|
textureX: x,
|
|
textureY: y,
|
|
zoom: Math.min(viewer.getZoomLevel(), 75),
|
|
speed: 500,
|
|
});
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
updateOcrBoxes(ocrManager.showOverlay, ocrManager.data);
|
|
});
|
|
|
|
/** Use updateOnly=true on zoom, pan, or resize. */
|
|
const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => {
|
|
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
|
|
return;
|
|
}
|
|
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
|
|
if (!showOverlay) {
|
|
markersPlugin.clearMarkers();
|
|
return;
|
|
}
|
|
if (!updateOnly) {
|
|
markersPlugin.clearMarkers();
|
|
}
|
|
|
|
const boxes = getOcrBoundingBoxes(ocrData, {
|
|
width: viewer.state.textureData.panoData.croppedWidth,
|
|
height: viewer.state.textureData.panoData.croppedHeight,
|
|
});
|
|
|
|
for (const [index, box] of boxes.entries()) {
|
|
const points = box.points.map((p) => texturePointToViewerPoint(viewer, p));
|
|
const { matrix, width, height } = calculateBoundingBoxMatrix(points);
|
|
|
|
const fontSize = (1.4 * width) / box.text.length; // fits almost all strings within the box, depends on font family
|
|
const transform = `matrix3d(${matrix.join(',')})`;
|
|
const content = `<div class="${OCR_TOOLTIP_HTML_CLASS}" style="font-size: ${fontSize}px; width: ${width}px; height: ${height}px; transform: ${transform}; transform-origin: 0 0;">${escape(box.text)}</div>`;
|
|
|
|
if (updateOnly) {
|
|
markersPlugin.updateMarker({
|
|
id: `box_${index}`,
|
|
polygonPixels: box.points.map((b) => [b.x, b.y]),
|
|
tooltip: { content },
|
|
});
|
|
} else {
|
|
markersPlugin.addMarker({
|
|
id: `box_${index}`,
|
|
polygonPixels: box.points.map((b) => [b.x, b.y]),
|
|
svgStyle: OCR_BOX_SVG_STYLE,
|
|
tooltip: { content, trigger: 'click' },
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const texturePointToViewerPoint = (viewer: Viewer, point: Point) => {
|
|
const spherical = viewer.dataHelper.textureCoordsToSphericalCoords({ textureX: point.x, textureY: point.y });
|
|
return viewer.dataHelper.sphericalCoordsToViewerCoords(spherical);
|
|
};
|
|
|
|
const onZoom = () => {
|
|
viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 });
|
|
};
|
|
|
|
let hasChangedResolution: boolean = false;
|
|
onMount(() => {
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
viewer = new Viewer({
|
|
adapter,
|
|
plugins: [
|
|
MarkersPlugin,
|
|
SettingsPlugin,
|
|
[
|
|
ResolutionPlugin,
|
|
{
|
|
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
|
resolutions: [
|
|
{
|
|
id: 'default',
|
|
label: 'Default',
|
|
panorama,
|
|
},
|
|
...(originalPanorama
|
|
? [
|
|
{
|
|
id: 'original',
|
|
label: 'Original',
|
|
panorama: originalPanorama,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
],
|
|
...plugins,
|
|
],
|
|
container,
|
|
touchmoveTwoFingers: false,
|
|
mousewheelCtrlKey: false,
|
|
navbar,
|
|
minFov: 15,
|
|
maxFov: 90,
|
|
zoomSpeed: 0.5,
|
|
fisheye: false,
|
|
});
|
|
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
|
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
|
// zoomLevel is 0-100
|
|
assetViewerManager.zoom = zoomLevel / 50;
|
|
|
|
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
|
// Replace the preview with the original
|
|
void resolutionPlugin.setResolution('original');
|
|
hasChangedResolution = true;
|
|
}
|
|
};
|
|
|
|
if (originalPanorama && !$alwaysLoadOriginalFile) {
|
|
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
|
|
}
|
|
|
|
const onReadyHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false);
|
|
const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true);
|
|
viewer.addEventListener(events.ReadyEvent.type, onReadyHandler);
|
|
viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler);
|
|
viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler);
|
|
viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true });
|
|
|
|
return () => {
|
|
viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler);
|
|
viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler);
|
|
viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler);
|
|
viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler);
|
|
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (viewer) {
|
|
viewer.destroy();
|
|
}
|
|
assetViewerManager.clearHighlightedFaces();
|
|
assetViewerManager.hideHiddenPeople();
|
|
assetViewerManager.zoom = 1;
|
|
});
|
|
</script>
|
|
|
|
<AssetViewerEvents {onZoom} />
|
|
|
|
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
|
<div class="mb-0 size-full" bind:this={container}></div>
|
|
|
|
<style>
|
|
/* Reset the default tooltip styling */
|
|
:global(.psv-tooltip) {
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
background: none;
|
|
box-shadow: none;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
:global(.psv-tooltip-content) {
|
|
font: var(--font-normal);
|
|
padding: 0;
|
|
text-shadow: none;
|
|
}
|
|
|
|
:global(.psv-tooltip-arrow) {
|
|
display: none;
|
|
}
|
|
</style>
|