mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 21:35:24 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98c19547a5 |
@@ -1300,7 +1300,6 @@
|
||||
"hide_schema": "Hide schema",
|
||||
"hide_text_recognition": "Hide text recognition",
|
||||
"hide_unnamed_people": "Hide unnamed people",
|
||||
"hold_key_to_pan": "Hold {key} to pan",
|
||||
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
|
||||
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
|
||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||
|
||||
@@ -74,8 +74,6 @@
|
||||
onError?: () => void;
|
||||
ref?: HTMLDivElement;
|
||||
imgRef?: HTMLImageElement;
|
||||
imgNaturalSize?: Size;
|
||||
imgScaledSize?: Size;
|
||||
backdrop?: Snippet;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
@@ -84,10 +82,6 @@
|
||||
ref = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgRef = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgNaturalSize = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgScaledSize = $bindable(),
|
||||
asset,
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
@@ -155,22 +149,10 @@
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
imgNaturalSize = imageDimensions;
|
||||
});
|
||||
|
||||
const scaledDimensions = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
return scaleFn(imageDimensions, container);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
imgScaledSize = scaledDimensions;
|
||||
});
|
||||
|
||||
const { insetInlineStart, top, displayWidth, displayHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
|
||||
() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
if (maxRasterPixels === 0) {
|
||||
return {
|
||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox, type BoundingBox } from '$lib/utils/people-utils';
|
||||
@@ -67,9 +67,13 @@
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||
const overlaySize = $derived.by((): Size => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
|
||||
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
|
||||
});
|
||||
|
||||
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
|
||||
const isHighlighting = $derived(highlightedBoxes.length > 0);
|
||||
@@ -231,7 +235,6 @@
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:imgScaledSize={scaledDimensions}
|
||||
bind:ref={adaptiveImage}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
|
||||
@@ -321,14 +321,24 @@
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoWidth;
|
||||
canvas.height = videoHeight;
|
||||
canvas.getContext('2d')?.drawImage(videoPlayer, 0, 0);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(videoPlayer, 0, 0);
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
canvas.width = 0;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => (assetViewerManager.imgRef = img);
|
||||
img.src = canvas.toDataURL('image/png');
|
||||
const onLoad = () => {
|
||||
assetViewerManager.imgRef = img;
|
||||
};
|
||||
img.addEventListener('load', onLoad);
|
||||
img.src = dataUrl;
|
||||
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.removeEventListener('load', onLoad);
|
||||
img.src = '';
|
||||
assetViewerManager.imgRef = undefined;
|
||||
};
|
||||
|
||||
@@ -6,14 +6,12 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
imageSize: Size;
|
||||
@@ -24,7 +22,6 @@
|
||||
let { imageSize, containerSize, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
let faceRect: Rect | undefined = $state();
|
||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||
@@ -35,9 +32,6 @@
|
||||
|
||||
let searchTerm = $state('');
|
||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||
let userMovedRect = false;
|
||||
let previousMetrics: ResizeContext | null = null;
|
||||
let panModifierHeld = $state(false);
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
@@ -63,8 +57,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
canvas = new Canvas(canvasEl, { width: containerSize.width, height: containerSize.height });
|
||||
canvas.selection = false;
|
||||
canvas = new Canvas(canvasEl);
|
||||
configureControlStyle();
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
@@ -82,58 +75,16 @@
|
||||
|
||||
canvas.add(faceRect);
|
||||
canvas.setActiveObject(faceRect);
|
||||
setDefaultFaceRectanglePosition(faceRect);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
setupCanvas();
|
||||
void getPeople();
|
||||
await getPeople();
|
||||
await tick();
|
||||
searchInputEl?.focus();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upperCanvas = canvas.upperCanvasEl;
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
const stopIfOnTarget = (event: PointerEvent) => {
|
||||
if (canvas?.findTarget(event).target) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
if (canvas.findTarget(event).target) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (faceRect) {
|
||||
event.stopPropagation();
|
||||
const pointer = canvas.getScenePoint(event);
|
||||
faceRect.set({ left: pointer.x, top: pointer.y });
|
||||
faceRect.setCoords();
|
||||
userMovedRect = true;
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
}
|
||||
};
|
||||
|
||||
upperCanvas.addEventListener('pointerdown', handlePointerDown, { signal });
|
||||
upperCanvas.addEventListener('pointermove', stopIfOnTarget, { signal });
|
||||
upperCanvas.addEventListener('pointerup', stopIfOnTarget, { signal });
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
|
||||
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
@@ -143,39 +94,28 @@
|
||||
top: offsetY + contentHeight / 2 - 56,
|
||||
left: offsetX + contentWidth / 2 - 56,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
positionFaceSelector();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
|
||||
|
||||
if (contentWidth === 0) {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstRun = previousMetrics === null;
|
||||
canvas.setDimensions({
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
});
|
||||
|
||||
if (isFirstRun && !canvas) {
|
||||
setupCanvas();
|
||||
}
|
||||
|
||||
if (!canvas || !faceRect) {
|
||||
if (!faceRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFirstRun) {
|
||||
canvas.setDimensions({ width: containerSize.width, height: containerSize.height });
|
||||
}
|
||||
|
||||
if (!isFirstRun && userMovedRect && previousMetrics) {
|
||||
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, { contentWidth, offsetX, offsetY }));
|
||||
} else {
|
||||
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
|
||||
setDefaultFaceRectanglePosition(faceRect);
|
||||
}
|
||||
|
||||
faceRect.setCoords();
|
||||
previousMetrics = { contentWidth, offsetX, offsetY };
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
|
||||
@@ -219,12 +159,11 @@
|
||||
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
|
||||
return;
|
||||
}
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const faceBox = {
|
||||
left: (rawBox.left - padding) * currentZoom + currentPositionX,
|
||||
top: (rawBox.top - padding) * currentZoom + currentPositionY,
|
||||
width: (rawBox.width + padding * 2) * currentZoom,
|
||||
height: (rawBox.height + padding * 2) * currentZoom,
|
||||
left: rawBox.left - padding,
|
||||
top: rawBox.top - padding,
|
||||
width: rawBox.width + padding * 2,
|
||||
height: rawBox.height + padding * 2,
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
@@ -234,21 +173,20 @@
|
||||
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
|
||||
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const overlapX = Math.max(
|
||||
0,
|
||||
Math.min(position.left + selectorWidth, faceRight) - Math.max(position.left, faceBox.left),
|
||||
);
|
||||
const overlapY = Math.max(
|
||||
0,
|
||||
Math.min(position.top + selectorHeight, faceBottom) - Math.max(position.top, faceBox.top),
|
||||
);
|
||||
const selectorRight = position.left + selectorWidth;
|
||||
const selectorBottom = position.top + selectorHeight;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
|
||||
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
|
||||
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
const faceBottom = faceBox.top + faceBox.height;
|
||||
const faceRight = faceBox.left + faceBox.width;
|
||||
|
||||
const positions = [
|
||||
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||
@@ -270,133 +208,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
const containerRect = containerEl?.getBoundingClientRect();
|
||||
const offsetTop = containerRect?.top ?? 0;
|
||||
const offsetLeft = containerRect?.left ?? 0;
|
||||
faceSelectorEl.style.top = `${bestPosition.top + offsetTop}px`;
|
||||
faceSelectorEl.style.left = `${bestPosition.left + offsetLeft}px`;
|
||||
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
||||
faceSelectorEl.style.left = `${bestPosition.left}px`;
|
||||
scrollableListEl.style.height = `${listHeight}px`;
|
||||
faceBoxPosition = faceBox;
|
||||
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
canvas.setViewportTransform([currentZoom, 0, 0, currentZoom, currentPositionX, currentPositionY]);
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const rect = faceRect;
|
||||
const cvs = canvas;
|
||||
if (rect && cvs) {
|
||||
const onUserMove = () => {
|
||||
userMovedRect = true;
|
||||
positionFaceSelector();
|
||||
};
|
||||
rect.on('moving', onUserMove);
|
||||
rect.on('scaling', onUserMove);
|
||||
rect.on('moving', positionFaceSelector);
|
||||
rect.on('scaling', positionFaceSelector);
|
||||
cvs.on('object:modified', () => searchInputEl?.focus());
|
||||
return () => {
|
||||
rect.off('moving', onUserMove);
|
||||
rect.off('scaling', onUserMove);
|
||||
rect.off('moving', positionFaceSelector);
|
||||
rect.off('scaling', positionFaceSelector);
|
||||
cvs.off('object:modified', () => searchInputEl?.focus());
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
const panModifierKey = isMac ? 'Meta' : 'Control';
|
||||
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
|
||||
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||
|
||||
$effect(() => {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
const element = containerEl;
|
||||
const parent = element.parentElement;
|
||||
|
||||
const activate = () => {
|
||||
panModifierHeld = true;
|
||||
element.style.pointerEvents = 'none';
|
||||
if (parent) {
|
||||
parent.style.cursor = 'move';
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = () => {
|
||||
panModifierHeld = false;
|
||||
element.style.pointerEvents = '';
|
||||
if (parent) {
|
||||
parent.style.cursor = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === panModifierKey) {
|
||||
activate();
|
||||
}
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === panModifierKey) {
|
||||
deactivate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', deactivate);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', deactivate);
|
||||
deactivate();
|
||||
};
|
||||
});
|
||||
|
||||
const trapEvents = (node: HTMLElement) => {
|
||||
const stop = (e: Event) => e.stopPropagation();
|
||||
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
|
||||
for (const type of eventTypes) {
|
||||
node.addEventListener(type, stop);
|
||||
}
|
||||
|
||||
document.body.append(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
for (const type of eventTypes) {
|
||||
node.removeEventListener(type, stop);
|
||||
}
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
|
||||
if (!faceRect || imageContentMetrics.contentWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaledWidth = faceRect.getScaledWidth();
|
||||
const scaledHeight = faceRect.getScaledHeight();
|
||||
|
||||
const imageRect = mapContentRectToNatural(
|
||||
{
|
||||
left: faceRect.left - scaledWidth / 2,
|
||||
top: faceRect.top - scaledHeight / 2,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
},
|
||||
imageContentMetrics,
|
||||
imageSize,
|
||||
);
|
||||
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, imageSize);
|
||||
|
||||
return {
|
||||
imageWidth: imageSize.width,
|
||||
@@ -418,16 +256,27 @@
|
||||
|
||||
const scaleX = imgRef.naturalWidth / imageSize.width;
|
||||
const scaleY = imgRef.naturalHeight / imageSize.height;
|
||||
|
||||
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
|
||||
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
|
||||
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
|
||||
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.getContext('2d')?.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
|
||||
context.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return;
|
||||
@@ -499,7 +348,6 @@
|
||||
|
||||
<div
|
||||
id="face-editor-data"
|
||||
bind:this={containerEl}
|
||||
class="absolute inset-s-0 top-0 z-5 size-full overflow-hidden"
|
||||
data-overlay-interactive
|
||||
data-face-left={faceBoxPosition.left}
|
||||
@@ -507,14 +355,12 @@
|
||||
data-face-width={faceBoxPosition.width}
|
||||
data-face-height={faceBoxPosition.height}
|
||||
>
|
||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute inset-s-0 top-0"></canvas>
|
||||
|
||||
<div
|
||||
id="face-selector"
|
||||
bind:this={faceSelectorEl}
|
||||
class="fixed z-20 w-[min(200px,45vw)] min-w-48 rounded-xl border border-gray-200 bg-white px-2 py-4 backdrop-blur-sm transition-[top,left] duration-200 ease-out dark:border-gray-800 dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
use:trapEvents
|
||||
onwheel={(e) => e.stopPropagation()}
|
||||
class="absolute inset-s-[calc(50%-125px)] top-[calc(50%-250px)] w-62.5 max-w-62.5 rounded-xl border border-gray-200 bg-white px-2 py-4 backdrop-blur-sm transition-[top,left] duration-200 ease-out dark:border-gray-800 dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||
|
||||
@@ -561,15 +407,4 @@
|
||||
{$t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if isZoomed && !panModifierHeld}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="pointer-events-none absolute inset-s-1/2 bottom-4 z-10 -translate-x-1/2"
|
||||
>
|
||||
<p class="whitespace-nowrap rounded-full bg-black/60 px-3 py-1.5 text-xs text-white">
|
||||
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -56,16 +56,16 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
|
||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||
};
|
||||
|
||||
export function computeContentMetrics(imageSize: Size, containerSize: Size): ContentMetrics {
|
||||
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||
export function computeContentMetrics(content: Size, container: Size): ContentMetrics {
|
||||
if (content.width === 0 || content.height === 0) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(imageSize, containerSize);
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(content, container);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerSize.width - contentWidth) / 2,
|
||||
offsetY: (containerSize.height - contentHeight) / 2,
|
||||
offsetX: (container.width - contentWidth) / 2,
|
||||
offsetY: (container.height - contentHeight) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function mapNormalizedRectToContent(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||||
function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||||
return {
|
||||
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
|
||||
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox, scaleFaceRectOnResize, type FaceRectState, type ResizeContext } from '$lib/utils/people-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
|
||||
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||
id: 'face-1',
|
||||
@@ -68,88 +68,3 @@ describe('getBoundingBox', () => {
|
||||
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaleFaceRectOnResize', () => {
|
||||
const makeRect = (overrides: Partial<FaceRectState> = {}): FaceRectState => ({
|
||||
left: 300,
|
||||
top: 400,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makePrevious = (overrides: Partial<ResizeContext> = {}): ResizeContext => ({
|
||||
offsetX: 100,
|
||||
offsetY: 50,
|
||||
contentWidth: 800,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should preserve relative position when container doubles in size', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 200, offsetY: 100, contentWidth: 1600 });
|
||||
|
||||
expect(result.left).toBe(600);
|
||||
expect(result.top).toBe(500);
|
||||
expect(result.scaleX).toBe(2);
|
||||
expect(result.scaleY).toBe(2);
|
||||
});
|
||||
|
||||
it('should preserve relative position when container halves in size', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 50, offsetY: 25, contentWidth: 400 });
|
||||
|
||||
expect(result.left).toBe(150);
|
||||
expect(result.top).toBe(125);
|
||||
expect(result.scaleX).toBe(0.5);
|
||||
expect(result.scaleY).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should handle no change in dimensions', () => {
|
||||
const rect = makeRect({ left: 300, top: 250, scaleX: 1.5, scaleY: 1.5 });
|
||||
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||
|
||||
expect(result.left).toBe(300);
|
||||
expect(result.top).toBe(250);
|
||||
expect(result.scaleX).toBe(1.5);
|
||||
expect(result.scaleY).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should handle offset changes without content width change', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 150, offsetY: 75, contentWidth: 800 });
|
||||
|
||||
expect(result.left).toBe(350);
|
||||
expect(result.top).toBe(275);
|
||||
expect(result.scaleX).toBe(1);
|
||||
expect(result.scaleY).toBe(1);
|
||||
});
|
||||
|
||||
it('should compound existing scale factors', () => {
|
||||
const rect = makeRect({ left: 300, top: 250, scaleX: 2, scaleY: 3 });
|
||||
const previous = makePrevious({ contentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, { ...previous, contentWidth: 1600 });
|
||||
|
||||
expect(result.scaleX).toBe(4);
|
||||
expect(result.scaleY).toBe(6);
|
||||
});
|
||||
|
||||
it('should handle rect at image origin', () => {
|
||||
const rect = makeRect({ left: 100, top: 50 });
|
||||
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 200, offsetY: 100, contentWidth: 1600 });
|
||||
|
||||
expect(result.left).toBe(200);
|
||||
expect(result.top).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { mapNormalizedRectToContent, type ContentMetrics, type Rect, type Size } from '$lib/utils/container-utils';
|
||||
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';
|
||||
|
||||
export type BoundingBox = Rect & { id: string };
|
||||
|
||||
@@ -21,32 +21,6 @@ export const getBoundingBox = (faces: Faces[], imageSize: Size): BoundingBox[] =
|
||||
return boxes;
|
||||
};
|
||||
|
||||
export type FaceRectState = {
|
||||
left: number;
|
||||
top: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
};
|
||||
|
||||
export type ResizeContext = Pick<ContentMetrics, 'contentWidth' | 'offsetX' | 'offsetY'>;
|
||||
|
||||
export const scaleFaceRectOnResize = (
|
||||
faceRect: FaceRectState,
|
||||
previous: ResizeContext,
|
||||
current: ResizeContext,
|
||||
): FaceRectState => {
|
||||
const scale = current.contentWidth / previous.contentWidth;
|
||||
const imageRelativeLeft = (faceRect.left - previous.offsetX) * scale;
|
||||
const imageRelativeTop = (faceRect.top - previous.offsetY) * scale;
|
||||
|
||||
return {
|
||||
left: current.offsetX + imageRelativeLeft,
|
||||
top: current.offsetY + imageRelativeTop,
|
||||
scaleX: faceRect.scaleX * scale,
|
||||
scaleY: faceRect.scaleY * scale,
|
||||
};
|
||||
};
|
||||
|
||||
export const zoomImageToBase64 = async (
|
||||
face: AssetFaceResponseDto,
|
||||
assetId: string,
|
||||
|
||||
Reference in New Issue
Block a user