Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis c8d0597359 fix(web): Create Person face preview not working for video assets
FaceEditor previously required an HTMLImageElement | HTMLVideoElement prop to
compute layout metrics and generate the face crop preview. This was unavailable
for video assets, so the preview thumbnail in the Create Person modal was always
missing, and face positions could be NaN during image load (naturalWidth is 0
before the image decodes).

Replace the DOM element prop with assetSize: Size and containerSize: Size, using
asset metadata dimensions that are always available from the API response.
computeContentMetrics() is extracted as a pure utility alongside
mapContentRectToNatural() for converting face rect coordinates back to original
image space.

For videos, VideoNativeViewer now captures the current frame to canvas when face
edit mode opens and sets assetViewerManager.imgRef, giving FaceEditor the same
image-based preview path as photo assets.

Change-Id: I0e9da549e3af40211abad4ab2c0270706a6a6964
2026-05-04 14:20:29 +00:00
12 changed files with 206 additions and 163 deletions
@@ -284,7 +284,11 @@
{/snippet} {/snippet}
</AdaptiveImage> </AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef} {#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor
assetSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
/>
{/if} {/if}
</div> </div>
@@ -140,9 +140,40 @@
let containerHeight = $state(0); let containerHeight = $state(0);
$effect(() => { $effect(() => {
if (assetViewerManager.isFaceEditMode) { if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
videoPlayer?.pause(); return;
} }
videoPlayer.pause();
const { videoWidth, videoHeight } = videoPlayer;
if (videoWidth === 0 || videoHeight === 0) {
return;
}
const canvas = document.createElement('canvas');
canvas.width = videoWidth;
canvas.height = videoHeight;
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();
const onLoad = () => {
assetViewerManager.imgRef = img;
};
img.addEventListener('load', onLoad);
img.src = dataUrl;
return () => {
img.removeEventListener('load', onLoad);
img.src = '';
assetViewerManager.imgRef = undefined;
};
}); });
</script> </script>
@@ -248,7 +279,11 @@
{/if} {/if}
{#if assetViewerManager.isFaceEditMode} {#if assetViewerManager.isFaceEditMode}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} /> <FaceEditor
assetSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
containerSize={{ width: containerWidth, height: containerHeight }}
{assetId}
/>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -4,7 +4,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte'; import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils'; import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk'; import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui'; import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -14,13 +14,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement; assetSize: Size;
containerWidth: number; containerSize: Size;
containerHeight: number;
assetId: string; assetId: string;
}; };
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let { assetSize, containerSize, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state(); let canvas: Canvas | undefined = $state();
@@ -54,7 +53,7 @@
}; };
const setupCanvas = () => { const setupCanvas = () => {
if (!canvasEl || !htmlElement) { if (!canvasEl) {
return; return;
} }
@@ -86,24 +85,14 @@
searchInputEl?.focus(); searchInputEl?.focus();
}); });
const imageContentMetrics = $derived.by(() => { const imageContentMetrics = $derived(computeContentMetrics(assetSize, containerSize));
const natural = getNaturalSize(htmlElement);
const container = { width: containerWidth, height: containerHeight };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
return {
contentWidth,
contentHeight,
offsetX: (containerWidth - contentWidth) / 2,
offsetY: (containerHeight - contentHeight) / 2,
};
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => { const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics; const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
faceRect.set({ faceRect.set({
top: offsetY + 200, top: offsetY + contentHeight / 2 - 56,
left: offsetX + 200, left: offsetX + contentWidth / 2 - 56,
}); });
faceRect.setCoords(); faceRect.setCoords();
@@ -116,8 +105,8 @@
} }
canvas.setDimensions({ canvas.setDimensions({
width: containerWidth, width: containerSize.width,
height: containerHeight, height: containerSize.height,
}); });
if (!faceRect) { if (!faceRect) {
@@ -167,6 +156,9 @@
const gap = 15; const gap = 15;
const padding = faceRect.padding ?? 0; const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect(); const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const faceBox = { const faceBox = {
left: rawBox.left - padding, left: rawBox.left - padding,
top: rawBox.top - padding, top: rawBox.top - padding,
@@ -175,11 +167,11 @@
}; };
const selectorWidth = faceSelectorEl.offsetWidth; const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight; const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight); const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight; const selectorHeight = listHeight + chromeHeight;
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap); const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap); const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => { const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth; const selectorRight = position.left + selectorWidth;
@@ -238,45 +230,37 @@
}); });
const getFaceCroppedCoordinates = () => { const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) { if (!faceRect || imageContentMetrics.contentWidth === 0) {
return; return;
} }
const { left, top, width, height } = faceRect.getBoundingRect(); const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, assetSize);
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
const scaleX = natural.width / contentWidth;
const scaleY = natural.height / contentHeight;
const imageX = (left - offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY;
return { return {
imageWidth: natural.width, imageWidth: assetSize.width,
imageHeight: natural.height, imageHeight: assetSize.height,
x: Math.floor(imageX), x: Math.floor(imageRect.left),
y: Math.floor(imageY), y: Math.floor(imageRect.top),
width: Math.floor(width * scaleX), width: Math.floor(imageRect.width),
height: Math.floor(height * scaleY), height: Math.floor(imageRect.height),
}; };
}; };
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>; type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => { const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) { const imgRef = assetViewerManager.imgRef;
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return; return;
} }
const natural = getNaturalSize(htmlElement); const scaleX = imgRef.naturalWidth / assetSize.width;
if (natural.width <= 0 || natural.height <= 0) { const scaleY = imgRef.naturalHeight / assetSize.height;
return;
}
const x = clamp(data.x, 0, natural.width - 1); const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
const y = clamp(data.y, 0, natural.height - 1); const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
const width = clamp(data.width, 1, natural.width - x); const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
const height = clamp(data.height, 1, natural.height - y); const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
return; return;
@@ -292,7 +276,7 @@
} }
try { try {
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height); context.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} catch { } catch {
return; return;
@@ -33,7 +33,6 @@
thumbnailSize?: number; thumbnailSize?: number;
thumbnailWidth?: number; thumbnailWidth?: number;
thumbnailHeight?: number; thumbnailHeight?: number;
isInViewport?: boolean;
selected?: boolean; selected?: boolean;
selectionCandidate?: boolean; selectionCandidate?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -57,7 +56,6 @@
thumbnailSize = undefined, thumbnailSize = undefined,
thumbnailWidth = undefined, thumbnailWidth = undefined,
thumbnailHeight = undefined, thumbnailHeight = undefined,
isInViewport = true,
selected = false, selected = false,
selectionCandidate = false, selectionCandidate = false,
disabled = false, disabled = false,
@@ -80,7 +78,6 @@
let mouseOver = $state(false); let mouseOver = $state(false);
let loaded = $state(false); let loaded = $state(false);
let thumbError = $state(false); let thumbError = $state(false);
let skipFade = $state(false);
let width = $derived(thumbnailSize || thumbnailWidth || 235); let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235);
@@ -267,11 +264,7 @@
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
curve={selected} curve={selected}
onComplete={(errored) => { onComplete={(errored) => ((loaded = true), (thumbError = errored))}
skipFade = !isInViewport;
loaded = true;
thumbError = errored;
}}
/> />
{#if asset.isVideo} {#if asset.isVideo}
<div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg"> <div class="pointer-events-none absolute size-full group-focus-visible:rounded-lg">
@@ -316,10 +309,7 @@
<Thumbhash <Thumbhash
base64ThumbHash={asset.thumbhash} base64ThumbHash={asset.thumbhash}
data-testid="thumbhash" data-testid="thumbhash"
class={[ class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
'absolute top-0 object-cover group-focus-visible:rounded-lg',
{ 'rounded-xl': selected, hidden: skipFade },
]}
style="width: {width}px; height: {height}px" style="width: {width}px; height: {height}px"
draggable="false" draggable="false"
fadeOut fadeOut
@@ -22,16 +22,11 @@
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
type Props = { type Props = {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
viewerAssets?: AssetResponseDto[]; viewerAssets?: AssetResponseDto[];
@@ -39,7 +34,7 @@
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
viewport: Viewport; viewport: Viewport;
onEndReached?: (() => void) | undefined; onIntersected?: (() => void) | undefined;
showAssetName?: boolean; showAssetName?: boolean;
onReload?: (() => void) | undefined; onReload?: (() => void) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
@@ -55,7 +50,7 @@
disableAssetSelect = false, disableAssetSelect = false,
showArchiveIcon = false, showArchiveIcon = false,
viewport, viewport,
onEndReached = undefined, onIntersected = undefined,
showAssetName = false, showAssetName = false,
onReload = undefined, onReload = undefined,
slidingWindowOffset = 0, slidingWindowOffset = 0,
@@ -75,31 +70,24 @@
}), }),
); );
const getStyle = (index: number) => { const getStyle = (i: number) => {
return `top: ${geometry.getTop(index)}px; left: ${geometry.getLeft(index)}px; width: ${geometry.getWidth(index)}px; height: ${geometry.getHeight(index)}px;`; const geo = geometry;
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
}; };
const isInOrNearViewport = (index: number) => { const isIntersecting = (i: number) => {
const geo = geometry;
const window = slidingWindow; const window = slidingWindow;
const top = geometry.getTop(index); const top = geo.getTop(i);
return top + pageHeaderOffset < window.bottom && top + geometry.getHeight(index) > window.top; return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
};
const isInViewport = (index: number) => {
const top = geometry.getTop(index) + pageHeaderOffset;
const bottom = top + geometry.getHeight(index);
const viewportTop = (scrollTop || 0) - slidingWindowOffset;
const viewportBottom = viewportTop + viewport.height + slidingWindowOffset;
return top < viewportBottom && bottom > viewportTop;
}; };
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0); let scrollTop = $state(0);
let slidingWindow = $derived.by(() => { let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset - INTERSECTION_EXPAND_TOP; const top = (scrollTop || 0) - slidingWindowOffset;
const bottom = top + viewport.height + slidingWindowOffset + INTERSECTION_EXPAND_BOTTOM; const bottom = top + viewport.height + slidingWindowOffset;
return { return {
top, top,
bottom, bottom,
@@ -113,15 +101,17 @@
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const debouncedOnEndReached = debounce(() => onEndReached?.(), 750, { maxWait: 100, leading: true }); const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
let lastEndReachedHeight = 0; let lastIntersectedHeight = 0;
$effect(() => { $effect(() => {
// Intersect if there's only one viewport worth of assets left to scroll.
if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) { if (geometry.containerHeight - slidingWindow.bottom <= viewport.height) {
const contentHeight = geometry.containerHeight; // Notify we got to (near) the end of scroll.
if (lastEndReachedHeight !== contentHeight) { const intersectedHeight = geometry.containerHeight;
debouncedOnEndReached(); if (lastIntersectedHeight !== intersectedHeight) {
lastEndReachedHeight = contentHeight; debouncedOnIntersected();
lastIntersectedHeight = intersectedHeight;
} }
} }
}); });
@@ -372,10 +362,10 @@
style:height={geometry.containerHeight + 'px'} style:height={geometry.containerHeight + 'px'}
style:width={geometry.containerWidth + 'px'} style:width={geometry.containerWidth + 'px'}
> >
{#each assets as asset, index (asset.id + '-' + index)} {#each assets as asset, i (asset.id + '-' + i)}
{#if isInOrNearViewport(index)} {#if isIntersecting(i)}
{@const currentAsset = toTimelineAsset(asset)} {@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(index)}> <div class="absolute" style:overflow="clip" style={getStyle(i)}>
<Thumbnail <Thumbnail
readonly={disableAssetSelect} readonly={disableAssetSelect}
onClick={() => { onClick={() => {
@@ -392,9 +382,8 @@
asset={currentAsset} asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)} selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
isInViewport={isInViewport(index)} thumbnailWidth={geometry.getWidth(i)}
thumbnailWidth={geometry.getWidth(index)} thumbnailHeight={geometry.getHeight(i)}
thumbnailHeight={geometry.getHeight(index)}
/> />
{#if showAssetName && !isTimelineAsset(asset)} {#if showAssetName && !isTimelineAsset(asset)}
<div <div
@@ -21,7 +21,6 @@
{ {
asset: TimelineAsset; asset: TimelineAsset;
position: CommonPosition; position: CommonPosition;
isInViewport: boolean;
}, },
] ]
>; >;
@@ -39,7 +38,6 @@
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)} {#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
{@const isInViewport = viewerAsset.isInViewport!}
<!-- note: don't remove data-asset-id - its used by web e2e tests --> <!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div <div
@@ -52,7 +50,7 @@
out:scale|global={{ start: 0.1, duration: scaleDuration }} out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }} animate:flip={{ duration: transitionDuration }}
> >
{@render thumbnail({ asset, position, isInViewport })} {@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)} {@render customThumbnailLayout?.(asset)}
</div> </div>
{/each} {/each}
+2 -3
View File
@@ -21,7 +21,6 @@
position: CommonPosition; position: CommonPosition;
timelineDay: TimelineDay; timelineDay: TimelineDay;
groupIndex: number; groupIndex: number;
isInViewport: boolean;
}, },
] ]
>; >;
@@ -106,8 +105,8 @@
width={timelineDay.width} width={timelineDay.width}
{customThumbnailLayout} {customThumbnailLayout}
> >
{#snippet thumbnail({ asset, position, isInViewport })} {#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex, isInViewport })} {@render thumbnailWithGroup({ asset, position, timelineDay, groupIndex })}
{/snippet} {/snippet}
</AssetLayout> </AssetLayout>
</section> </section>
@@ -673,7 +673,7 @@
manager={timelineManager} manager={timelineManager}
onTimelineDaySelect={handleGroupSelect} onTimelineDaySelect={handleGroupSelect}
> >
{#snippet thumbnail({ asset, position, timelineDay, groupIndex, isInViewport })} {#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected = {@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
@@ -684,7 +684,6 @@
{asset} {asset}
{albumUsers} {albumUsers}
{groupIndex} {groupIndex}
{isInViewport}
onClick={(asset) => { onClick={(asset) => {
if (typeof onThumbnailClick === 'function') { if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, timelineDay, _onClick); onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
@@ -3,7 +3,6 @@ import {
ViewportProximity, ViewportProximity,
calculateViewerAssetViewportProximity, calculateViewerAssetViewportProximity,
isInOrNearViewport, isInOrNearViewport,
isInViewport,
} from './internal/intersection-support.svelte'; } from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte'; import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
@@ -26,10 +25,6 @@ export class ViewerAsset {
return isInOrNearViewport(this.#viewportProximity); return isInOrNearViewport(this.#viewportProximity);
} }
get isInViewport() {
return isInViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset; asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
+68 -34
View File
@@ -1,18 +1,15 @@
import { import {
getContentMetrics, computeContentMetrics,
getNaturalSize, getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent, mapNormalizedRectToContent,
mapNormalizedToContent, mapNormalizedToContent,
scaleToCover, scaleToCover,
scaleToFit, scaleToFit,
} from '$lib/utils/container-utils'; } from '$lib/utils/container-utils';
const mockImage = (props: { const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
naturalWidth: number; props as unknown as HTMLImageElement;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockVideo = (props: { const mockVideo = (props: {
videoWidth: number; videoWidth: number;
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
}); });
}); });
describe('getContentMetrics', () => { describe('computeContentMetrics', () => {
it('should compute zero offsets when aspect ratios match', () => { it('should return zero metrics for zero-width content', () => {
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 }); expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({
expect(getContentMetrics(img)).toEqual({ contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should return zero metrics for zero-height content', () => {
expect(computeContentMetrics({ width: 1920, height: 0 }, { width: 800, height: 600 })).toEqual({
contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should center wide content vertically', () => {
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 800,
contentHeight: 400,
offsetX: 0,
offsetY: 100,
});
});
it('should center tall content horizontally', () => {
expect(computeContentMetrics({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 300,
contentHeight: 600,
offsetX: 250,
offsetY: 0,
});
});
it('should produce zero offsets when aspect ratios match', () => {
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
contentWidth: 800, contentWidth: 800,
contentHeight: 450, contentHeight: 450,
offsetX: 0, offsetX: 0,
offsetY: 0, offsetY: 0,
}); });
}); });
it('should compute horizontal letterbox offsets for tall image', () => {
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(300);
expect(metrics.contentHeight).toBe(600);
expect(metrics.offsetX).toBe(250);
expect(metrics.offsetY).toBe(0);
}); });
it('should compute vertical letterbox offsets for wide image', () => { describe('mapContentRectToNatural', () => {
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 }); it('should map a full-content rect back to natural size', () => {
const metrics = getContentMetrics(img); const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
expect(metrics.contentWidth).toBe(800); const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
expect(metrics.contentHeight).toBe(400); width: 2000,
expect(metrics.offsetX).toBe(0); height: 1000,
expect(metrics.offsetY).toBe(100); });
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
}); });
it('should use clientWidth/clientHeight for video elements', () => { it('should map a centered sub-rect to natural coordinates', () => {
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 }); const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const metrics = getContentMetrics(video); const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
expect(metrics.contentWidth).toBe(800); width: 2000,
expect(metrics.contentHeight).toBe(450); height: 1000,
expect(metrics.offsetX).toBe(0); });
expect(metrics.offsetY).toBe(75); expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
});
it('should handle letterboxed content with horizontal offset', () => {
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
width: 1000,
height: 2000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
}); });
}); });
describe('getNaturalSize', () => { describe('getNaturalSize', () => {
it('should return naturalWidth/naturalHeight for images', () => { it('should return naturalWidth/naturalHeight for images', () => {
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 }); const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 }); expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
}); });
+30 -14
View File
@@ -49,13 +49,6 @@ export const scaleToFit = (dimensions: Size, container: Size): Size => {
}; };
}; };
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => { export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) { if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight }; return { width: element.videoWidth, height: element.videoHeight };
@@ -63,17 +56,18 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
return { width: element.naturalWidth, height: element.naturalHeight }; return { width: element.naturalWidth, height: element.naturalHeight };
}; };
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => { export function computeContentMetrics(content: Size, container: Size): ContentMetrics {
const natural = getNaturalSize(element); if (content.width === 0 || content.height === 0) {
const client = getElementSize(element); return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client); }
const { width: contentWidth, height: contentHeight } = scaleToFit(content, container);
return { return {
contentWidth, contentWidth,
contentHeight, contentHeight,
offsetX: (client.width - contentWidth) / 2, offsetX: (container.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2, offsetY: (container.height - contentHeight) / 2,
};
}; };
}
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point { export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) { if ('contentWidth' in sizeOrMetrics) {
@@ -109,3 +103,25 @@ export function mapNormalizedRectToContent(
height: br.y - tl.y, height: br.y - tl.y,
}; };
} }
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,
};
}
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
const bottomRight = mapContentToNatural(
{ x: rect.left + rect.width, y: rect.top + rect.height },
metrics,
naturalSize,
);
return {
top: topLeft.y,
left: topLeft.x,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
};
}
@@ -292,7 +292,7 @@
<GalleryViewer <GalleryViewer
assets={searchResultAssets} assets={searchResultAssets}
assetInteraction={assetMultiSelectManager} assetInteraction={assetMultiSelectManager}
onEndReached={loadNextPage} onIntersected={loadNextPage}
showArchiveIcon={true} showArchiveIcon={true}
{viewport} {viewport}
onReload={onSearchQueryUpdate} onReload={onSearchQueryUpdate}