mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 04:35:24 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b41195d22 |
@@ -10,6 +10,7 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
@@ -102,9 +103,10 @@
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
let stack: StackResponseDto | undefined = $state();
|
||||
let selectedStackAsset: AssetResponseDto | undefined = $state();
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? cursor.current);
|
||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let sharedLink = getSharedLink();
|
||||
@@ -117,17 +119,29 @@
|
||||
playOriginalVideo = value;
|
||||
};
|
||||
|
||||
const selectStackedAsset = async (id: string) => {
|
||||
ocrManager.clear();
|
||||
selectedStackAsset = await assetCacheManager.getAsset({ id });
|
||||
if (!sharedLink) {
|
||||
await ocrManager.getAssetOcr(id);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
if (!cursor.current.stack) {
|
||||
stack = undefined;
|
||||
selectedStackAsset = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
stack = await getStack({ id: cursor.current.stack.id });
|
||||
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
|
||||
if (primaryAsset) {
|
||||
await selectStackedAsset(primaryAsset.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,11 +199,21 @@
|
||||
onClose?.(asset.id);
|
||||
};
|
||||
|
||||
const refreshPreservingSelection = async () => {
|
||||
const id = asset.id;
|
||||
assetCacheManager.invalidateAsset(id);
|
||||
if (selectedStackAsset) {
|
||||
await selectStackedAsset(id);
|
||||
} else {
|
||||
const refreshedAsset = await assetCacheManager.getAsset({ id });
|
||||
assetViewerManager.setAsset(refreshedAsset);
|
||||
}
|
||||
onAssetChange?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewerManager.setAsset(refreshedAsset);
|
||||
await refreshPreservingSelection();
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
@@ -304,10 +328,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
@@ -320,7 +340,7 @@
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack;
|
||||
stack = action.stack ?? undefined;
|
||||
if (stack) {
|
||||
cursor.current = stack.assets[0];
|
||||
}
|
||||
@@ -328,7 +348,7 @@
|
||||
}
|
||||
case AssetAction.STACK:
|
||||
case AssetAction.SET_STACK_PRIMARY_ASSET: {
|
||||
stack = action.stack;
|
||||
stack = action.stack ?? undefined;
|
||||
break;
|
||||
}
|
||||
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
|
||||
@@ -391,7 +411,7 @@
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
cursor.current;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
});
|
||||
|
||||
@@ -560,7 +580,12 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
{onSwipe}
|
||||
onTagFace={refreshPreservingSelection}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@@ -617,7 +642,7 @@
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
{/if}
|
||||
@@ -629,27 +654,24 @@
|
||||
<div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full">
|
||||
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
|
||||
<div
|
||||
class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
style:bottom={isSelected ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||
imageClass={{ 'border-2 border-white': isSelected }}
|
||||
brokenAssetClass="text-xs"
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
dimmed={!isSelected}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
onClick={() => selectStackedAsset(stackedAsset.id)}
|
||||
readonly
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
{#if stackedAsset.id === asset.id}
|
||||
{#if isSelected}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<div class="mt-0.5 flex size-2 rounded-full bg-white"></div>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,7 @@
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
@@ -37,9 +31,10 @@
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
onRefreshPeople?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
|
||||
|
||||
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
|
||||
let latlng = $derived(
|
||||
@@ -94,11 +89,6 @@
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
assetViewerManager.closeEditFacesPanel();
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
// Remove the last part of the path to get the parent path
|
||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||
@@ -385,6 +375,6 @@
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => assetViewerManager.closeEditFacesPanel()}
|
||||
onRefresh={handleRefreshPeople}
|
||||
onRefresh={() => void onRefreshPeople?.()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
onTagFace?: () => Promise<void>;
|
||||
};
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
@@ -286,11 +287,13 @@
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
|
||||
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
|
||||
<FaceEditor
|
||||
imageSize={{ width: asset.width, height: asset.height }}
|
||||
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||
htmlElement={assetViewerManager.imgRef}
|
||||
{containerWidth}
|
||||
{containerHeight}
|
||||
assetId={asset.id}
|
||||
{onTagFace}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -308,30 +308,9 @@
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
|
||||
return;
|
||||
if (assetViewerManager.isFaceEditMode) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
videoPlayer.pause();
|
||||
|
||||
const { videoWidth, videoHeight } = videoPlayer;
|
||||
if (videoWidth === 0 || videoHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoWidth;
|
||||
canvas.height = videoHeight;
|
||||
canvas.getContext('2d')?.drawImage(videoPlayer, 0, 0);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => (assetViewerManager.imgRef = img);
|
||||
img.src = canvas.toDataURL('image/png');
|
||||
|
||||
return () => {
|
||||
img.onload = null;
|
||||
img.src = '';
|
||||
assetViewerManager.imgRef = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
// The time is only refreshed on HLS fragment decode by default,
|
||||
@@ -475,12 +454,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode}
|
||||
<FaceEditor
|
||||
imageSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
|
||||
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||
{assetId}
|
||||
/>
|
||||
{#if assetViewerManager.isFaceEditMode && videoPlayer}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit } 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';
|
||||
@@ -14,12 +14,14 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
imageSize: Size;
|
||||
containerSize: Size;
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
onTagFace?: () => Promise<void>;
|
||||
};
|
||||
|
||||
let { imageSize, containerSize, assetId }: Props = $props();
|
||||
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
@@ -53,7 +55,7 @@
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl) {
|
||||
if (!canvasEl || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +87,17 @@
|
||||
searchInputEl?.focus();
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
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 { offsetX, offsetY } = imageContentMetrics;
|
||||
@@ -105,8 +117,8 @@
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
if (!faceRect) {
|
||||
@@ -164,11 +176,11 @@
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
||||
const selectorHeight = listHeight + chromeHeight;
|
||||
|
||||
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
|
||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const selectorRight = position.left + selectorWidth;
|
||||
@@ -227,42 +239,61 @@
|
||||
});
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || imageContentMetrics.contentWidth === 0) {
|
||||
if (!faceRect || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, imageSize);
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
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 {
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
x: Math.floor(imageRect.left),
|
||||
y: Math.floor(imageRect.top),
|
||||
width: Math.floor(imageRect.width),
|
||||
height: Math.floor(imageRect.height),
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageX),
|
||||
y: Math.floor(imageY),
|
||||
width: Math.floor(width * scaleX),
|
||||
height: Math.floor(height * scaleY),
|
||||
};
|
||||
};
|
||||
|
||||
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
|
||||
|
||||
const getFacePreviewUrl = (data: FaceCoordinates) => {
|
||||
const imgRef = assetViewerManager.imgRef;
|
||||
if (!imgRef || imageContentMetrics.contentWidth === 0) {
|
||||
if (!htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
if (natural.width <= 0 || natural.height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = clamp(data.x, 0, natural.width - 1);
|
||||
const y = clamp(data.y, 0, natural.height - 1);
|
||||
const width = clamp(data.width, 1, natural.width - x);
|
||||
const height = clamp(data.height, 1, natural.height - 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(htmlElement, x, y, width, height, 0, 0, width, height);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return;
|
||||
@@ -295,7 +326,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
await onTagFace?.();
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
|
||||
@@ -178,7 +178,10 @@
|
||||
|
||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
onRefresh();
|
||||
if (peopleWithFaces.length === 0) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('error_delete_face'));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
computeContentMetrics,
|
||||
getContentMetrics,
|
||||
getNaturalSize,
|
||||
mapContentRectToNatural,
|
||||
mapNormalizedRectToContent,
|
||||
mapNormalizedToContent,
|
||||
scaleToCover,
|
||||
scaleToFit,
|
||||
} from '$lib/utils/container-utils';
|
||||
|
||||
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
|
||||
props as unknown as HTMLImageElement;
|
||||
const mockImage = (props: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
||||
|
||||
const mockVideo = (props: {
|
||||
videoWidth: number;
|
||||
@@ -46,85 +49,48 @@ describe('scaleToFit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeContentMetrics', () => {
|
||||
it('should return zero metrics for zero-width content', () => {
|
||||
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).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({
|
||||
describe('getContentMetrics', () => {
|
||||
it('should compute zero offsets when aspect ratios match', () => {
|
||||
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
|
||||
expect(getContentMetrics(img)).toEqual({
|
||||
contentWidth: 800,
|
||||
contentHeight: 450,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapContentRectToNatural', () => {
|
||||
it('should map a full-content rect back to natural size', () => {
|
||||
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
});
|
||||
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
|
||||
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 map a centered sub-rect to natural coordinates', () => {
|
||||
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
});
|
||||
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
|
||||
it('should compute vertical letterbox offsets for wide image', () => {
|
||||
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(400);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(100);
|
||||
});
|
||||
|
||||
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 });
|
||||
it('should use clientWidth/clientHeight for video elements', () => {
|
||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||
const metrics = getContentMetrics(video);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(450);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNaturalSize', () => {
|
||||
it('should return naturalWidth/naturalHeight for images', () => {
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
||||
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ 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 => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.videoWidth, height: element.videoHeight };
|
||||
@@ -56,18 +63,17 @@ 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) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(imageSize, containerSize);
|
||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||
const natural = getNaturalSize(element);
|
||||
const client = getElementSize(element);
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerSize.width - contentWidth) / 2,
|
||||
offsetY: (containerSize.height - contentHeight) / 2,
|
||||
offsetX: (client.width - contentWidth) / 2,
|
||||
offsetY: (client.height - contentHeight) / 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||
if ('contentWidth' in sizeOrMetrics) {
|
||||
@@ -103,25 +109,3 @@ export function mapNormalizedRectToContent(
|
||||
height: br.y - tl.y,
|
||||
};
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user