mirror of
https://github.com/immich-app/immich.git
synced 2026-03-09 11:23:46 -04:00
feat(web): face editor preserves position and proportions on resize
Face editor selection rectangle scales and repositions with zoom, window resize, and detail panel open/close. Move clientWidth/clientHeight bindings to the data-viewer-content div so the face editor respects viewer bounds and does not draw under the detail panel.
This commit is contained in:
parent
39d8c6d048
commit
471f4ddeef
@ -483,8 +483,6 @@
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
@ -526,7 +524,12 @@
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
<div
|
||||
data-viewer-content
|
||||
class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleFaceRectOnResize } 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';
|
||||
@ -31,6 +32,10 @@
|
||||
|
||||
let searchTerm = $state('');
|
||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||
let initialized = false;
|
||||
let previousContentWidth = 0;
|
||||
let previousOffsetX = 0;
|
||||
let previousOffsetY = 0;
|
||||
|
||||
let filteredCandidates = $derived(
|
||||
searchTerm
|
||||
@ -94,27 +99,47 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { offsetX, offsetY } = imageContentMetrics;
|
||||
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
|
||||
|
||||
if (!canvas) {
|
||||
if (!canvas || contentWidth === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||
|
||||
if (!faceRect) {
|
||||
if (faceRect) {
|
||||
faceRect.set({ top: offsetY + 200, left: offsetX + 200 });
|
||||
faceRect.setCoords();
|
||||
}
|
||||
|
||||
previousContentWidth = contentWidth;
|
||||
previousOffsetX = offsetX;
|
||||
previousOffsetY = offsetY;
|
||||
positionFaceSelector();
|
||||
return;
|
||||
}
|
||||
|
||||
faceRect.set({
|
||||
top: offsetY + 200,
|
||||
left: offsetX + 200,
|
||||
});
|
||||
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||
|
||||
faceRect.setCoords();
|
||||
if (faceRect && previousContentWidth > 0) {
|
||||
const scaled = scaleFaceRectOnResize(
|
||||
{ left: faceRect.left, top: faceRect.top, scaleX: faceRect.scaleX, scaleY: faceRect.scaleY },
|
||||
{ previousOffsetX, previousOffsetY, previousContentWidth },
|
||||
offsetX,
|
||||
offsetY,
|
||||
contentWidth,
|
||||
);
|
||||
faceRect.set(scaled);
|
||||
faceRect.setCoords();
|
||||
}
|
||||
|
||||
previousContentWidth = contentWidth;
|
||||
previousOffsetX = offsetX;
|
||||
previousOffsetY = offsetY;
|
||||
|
||||
canvas.renderAll();
|
||||
positionFaceSelector();
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getBoundingBox, scaleFaceRectOnResize, type FaceRectState, type ResizeContext } from '$lib/utils/people-utils';
|
||||
|
||||
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||
id: 'face-1',
|
||||
@ -97,3 +97,96 @@ 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 => ({
|
||||
previousOffsetX: 100,
|
||||
previousOffsetY: 50,
|
||||
previousContentWidth: 800,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should preserve relative position when container doubles in size', () => {
|
||||
const rect = makeRect({ left: 300, top: 250 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 200, 100, 1600);
|
||||
|
||||
// imageRelLeft = (300 - 100) * 2 = 400, new left = 200 + 400 = 600
|
||||
// imageRelTop = (250 - 50) * 2 = 400, new top = 100 + 400 = 500
|
||||
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({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 50, 25, 400);
|
||||
|
||||
// imageRelLeft = (300 - 100) * 0.5 = 100, new left = 50 + 100 = 150
|
||||
// imageRelTop = (250 - 50) * 0.5 = 100, new top = 25 + 100 = 125
|
||||
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({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 100, 50, 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({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 150, 75, 800);
|
||||
|
||||
// scale = 1, imageRelLeft = 200, imageRelTop = 200
|
||||
// new left = 150 + 200 = 350, new top = 75 + 200 = 275
|
||||
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({ previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, previous.previousOffsetX, previous.previousOffsetY, 1600);
|
||||
|
||||
expect(result.scaleX).toBe(4);
|
||||
expect(result.scaleY).toBe(6);
|
||||
});
|
||||
|
||||
it('should handle rect at image origin (top-left of content area)', () => {
|
||||
const rect = makeRect({ left: 100, top: 50 });
|
||||
const previous = makePrevious({ previousOffsetX: 100, previousOffsetY: 50, previousContentWidth: 800 });
|
||||
|
||||
const result = scaleFaceRectOnResize(rect, previous, 200, 100, 1600);
|
||||
|
||||
// imageRelLeft = (100 - 100) * 2 = 0, new left = 200
|
||||
// imageRelTop = (50 - 50) * 2 = 0, new top = 100
|
||||
expect(result.left).toBe(200);
|
||||
expect(result.top).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,6 +37,38 @@ export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): Boundin
|
||||
return boxes;
|
||||
};
|
||||
|
||||
export type FaceRectState = {
|
||||
left: number;
|
||||
top: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
};
|
||||
|
||||
export type ResizeContext = {
|
||||
previousOffsetX: number;
|
||||
previousOffsetY: number;
|
||||
previousContentWidth: number;
|
||||
};
|
||||
|
||||
export const scaleFaceRectOnResize = (
|
||||
faceRect: FaceRectState,
|
||||
previous: ResizeContext,
|
||||
newOffsetX: number,
|
||||
newOffsetY: number,
|
||||
newContentWidth: number,
|
||||
): FaceRectState => {
|
||||
const scale = newContentWidth / previous.previousContentWidth;
|
||||
const imageRelativeLeft = (faceRect.left - previous.previousOffsetX) * scale;
|
||||
const imageRelativeTop = (faceRect.top - previous.previousOffsetY) * scale;
|
||||
|
||||
return {
|
||||
left: newOffsetX + imageRelativeLeft,
|
||||
top: newOffsetY + imageRelativeTop,
|
||||
scaleX: faceRect.scaleX * scale,
|
||||
scaleY: faceRect.scaleY * scale,
|
||||
};
|
||||
};
|
||||
|
||||
export const zoomImageToBase64 = async (
|
||||
face: AssetFaceResponseDto,
|
||||
assetId: string,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user