diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 5115ab18f1..7cb8a5c076 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -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} > {#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor} @@ -526,7 +524,12 @@ {/if} -
+
{#if viewerKind === 'StackVideoViewer'} { - 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(); }); diff --git a/web/src/lib/utils/people-utils.spec.ts b/web/src/lib/utils/people-utils.spec.ts index 80371bd9c4..89f79a8d13 100644 --- a/web/src/lib/utils/people-utils.spec.ts +++ b/web/src/lib/utils/people-utils.spec.ts @@ -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 => ({ id: 'face-1', @@ -97,3 +97,96 @@ describe('getBoundingBox', () => { expect(boxes[0].left).toBeLessThan(boxes[1].left); }); }); + +describe('scaleFaceRectOnResize', () => { + const makeRect = (overrides: Partial = {}): FaceRectState => ({ + left: 300, + top: 400, + scaleX: 1, + scaleY: 1, + ...overrides, + }); + + const makePrevious = (overrides: Partial = {}): 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); + }); +}); diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index b8fb8973e6..81a76ee7f0 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -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,