refactor: extract shared ContentMetrics for overlay position calculations (#26310)

This commit is contained in:
Min Idzelis
2026-03-02 22:49:56 -05:00
committed by GitHub
parent 6deb97d5bc
commit 20c639e52a
9 changed files with 415 additions and 162 deletions
@@ -3,6 +3,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -81,17 +82,13 @@
});
$effect(() => {
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const metrics = getContentMetrics(htmlElement);
const imageBoundingBox = {
top: offsetArea.height,
left: offsetArea.width,
width: containerWidth - offsetArea.width * 2,
height: containerHeight - offsetArea.height * 2,
top: metrics.offsetY,
left: metrics.offsetX,
width: metrics.contentWidth,
height: metrics.contentHeight,
};
if (!canvas) {
@@ -116,35 +113,6 @@
positionFaceSelector();
});
const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement) => {
if (element instanceof HTMLImageElement) {
return {
naturalWidth: element.naturalWidth,
naturalHeight: element.naturalHeight,
displayWidth: element.width,
displayHeight: element.height,
};
}
return {
naturalWidth: element.videoWidth,
naturalHeight: element.videoHeight,
displayWidth: element.clientWidth,
displayHeight: element.clientHeight,
};
};
const getContainedSize = (element: HTMLImageElement | HTMLVideoElement) => {
const { naturalWidth, naturalHeight, displayWidth, displayHeight } = getNaturalSize(element);
const ratio = naturalWidth / naturalHeight;
let actualWidth = displayHeight * ratio;
let actualHeight = displayHeight;
if (actualWidth > displayWidth) {
actualWidth = displayWidth;
actualHeight = displayWidth / ratio;
}
return { actualWidth, actualHeight };
};
const cancel = () => {
isFaceEditMode.value = false;
};
@@ -245,22 +213,23 @@
return;
}
const faceBox = faceRect.getBoundingRect();
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const { naturalWidth, naturalHeight } = getNaturalSize(htmlElement);
const { left, top, width, height } = faceRect.getBoundingRect();
const metrics = getContentMetrics(htmlElement);
const natural = getNaturalSize(htmlElement);
const offsetX = (containerWidth - actualWidth) / 2;
const offsetY = (containerHeight - actualHeight) / 2;
const scaleX = natural.width / metrics.contentWidth;
const scaleY = natural.height / metrics.contentHeight;
const imageX = (left - metrics.offsetX) * scaleX;
const imageY = (top - metrics.offsetY) * scaleY;
const scaleX = naturalWidth / actualWidth;
const scaleY = naturalHeight / actualHeight;
const x = Math.floor((faceBox.left - offsetX) * scaleX);
const y = Math.floor((faceBox.top - offsetY) * scaleY);
const width = Math.floor(faceBox.width * scaleX);
const height = Math.floor(faceBox.height * scaleY);
return { imageWidth: naturalWidth, imageHeight: naturalHeight, x, y, width, height };
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
};
};
const tagFace = async (person: PersonResponseDto) => {
@@ -5,7 +5,7 @@
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 { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import {
EquirectangularAdapter,
Viewer,
@@ -127,9 +127,11 @@
markersPlugin.clearMarkers();
}
const boxes = getOcrBoundingBoxesAtSize(ocrData, {
width: viewer.state.textureData.panoData.croppedWidth,
height: viewer.state.textureData.panoData.croppedHeight,
const boxes = getOcrBoundingBoxes(ocrData, {
contentWidth: viewer.state.textureData.panoData.croppedWidth,
contentHeight: viewer.state.textureData.panoData.croppedHeight,
offsetX: 0,
offsetY: 0,
});
for (const [index, box] of boxes.entries()) {
@@ -15,6 +15,7 @@
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
@@ -52,6 +53,7 @@
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let visibleImageReady: boolean = $state(false);
let loader = $state<HTMLImageElement>();
@@ -67,11 +69,23 @@
$boundingBoxesArray = [];
});
let ocrBoxes = $derived(
ocrManager.showOverlay && assetViewerManager.imgRef
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
: [],
);
const overlayMetrics = $derived.by((): ContentMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
return {
contentWidth: contentWidth * currentZoom,
contentHeight: contentHeight * currentZoom,
offsetX: offsetX * currentZoom + currentPositionX,
offsetY: offsetY * currentZoom + currentPositionY,
};
});
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
let isOcrActive = $derived(ocrManager.showOverlay);
@@ -176,6 +190,7 @@
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
visibleImageReady = false;
});
}
lastUrl = imageLoaderUrl;
@@ -226,6 +241,7 @@
<img
bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl}
onload={() => (visibleImageReady = true)}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
@@ -233,7 +249,7 @@
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"