Compare commits

...

5 Commits

Author SHA1 Message Date
midzelis
1ee6d3406e 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-04-25 18:59:30 +00:00
Savely Krasovsky
9263e2f2e1 feat(ml): update Intel graphics compiler and compute runtime (#28076)
feat(ml): update Intel graphics compiler and compute runtime to latest versions
2026-04-25 08:49:57 -04:00
Aaron Liu
a3ee615c5b chore(ml): update huggingfacehub and pillow (#27552) 2026-04-24 19:44:01 -04:00
Yaros
39cfad7136 feat(mobile): action bottom sheet on map timeline (#27515) 2026-04-24 09:30:10 -05:00
renovate[bot]
350056dd1a fix(deps): update dependency uuid to v14 [security] (#28046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 11:24:33 +02:00
13 changed files with 208 additions and 118 deletions

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -9,12 +9,12 @@ dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"huggingface-hub>=1.0,<2.0",
"insightface>=0.7.3,<1.0",
"numpy<2.4.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=12.2,<12.3",
"pillow>=12.2,<13",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",

View File

@@ -2,17 +2,21 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class MapBottomSheet extends StatelessWidget {
const MapBottomSheet({super.key});
final Key? sheetKey;
const MapBottomSheet({super.key, this.sheetKey});
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
key: sheetKey,
initialChildSize: 0.25,
maxChildSize: 0.75,
shouldCloseOnMinExtent: false,
@@ -49,7 +53,7 @@ class _ScopedMapTimeline extends StatelessWidget {
return timelineService;
}),
],
child: const Timeline(appBar: null, bottomSheet: null, withScrubber: false),
child: const Timeline(appBar: null, bottomSheet: GeneralBottomSheet(minChildSize: 0.23), withScrubber: false),
);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
@@ -53,6 +54,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
final GlobalKey _bottomSheetKey = GlobalKey();
StreamSubscription? _eventSubscription;
@override
@@ -184,7 +186,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack(
children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset, sheetKey: _bottomSheetKey),
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
],
);
@@ -224,8 +226,9 @@ class _Map extends StatelessWidget {
class _DynamicBottomSheet extends StatefulWidget {
final ValueNotifier<double> bottomSheetOffset;
final GlobalKey sheetKey;
const _DynamicBottomSheet({required this.bottomSheetOffset});
const _DynamicBottomSheet({required this.bottomSheetOffset, required this.sheetKey});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
@@ -236,10 +239,13 @@ class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
Widget build(BuildContext context) {
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
widget.bottomSheetOffset.value = notification.extent;
return true;
final sheet = notification.context.findAncestorWidgetOfExactType<BaseBottomSheet>();
if (sheet?.key == widget.sheetKey) {
widget.bottomSheetOffset.value = notification.extent;
}
return false;
},
child: const MapBottomSheet(),
child: MapBottomSheet(sheetKey: widget.sheetKey),
);
}
}

View File

@@ -469,6 +469,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
ref.read(timelineStateProvider.notifier).setScrolling(true);
},
child: Stack(
clipBehavior: Clip.none,
children: [
timeline,
if (isBottomWidgetVisible)

10
pnpm-lock.yaml generated
View File

@@ -570,8 +570,8 @@ importers:
specifier: ^2.0.0
version: 2.0.9
uuid:
specifier: ^11.1.0
version: 11.1.0
specifier: ^14.0.0
version: 14.0.0
validator:
specifier: ^13.12.0
version: 13.15.35
@@ -12110,6 +12110,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -25779,6 +25783,8 @@ snapshots:
uuid@11.1.0: {}
uuid@14.0.0: {}
uuid@8.3.2: {}
validator@13.15.35: {}

View File

@@ -114,7 +114,7 @@
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "^4.3.6"
},

View File

@@ -284,7 +284,11 @@
{/snippet}
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
<FaceEditor
assetSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
/>
{/if}
</div>

View File

@@ -11,6 +11,7 @@
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import type { Size } from '$lib/utils/container-utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
@@ -19,6 +20,7 @@
interface Props {
assetId: string;
assetSize: Size;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
@@ -31,6 +33,7 @@
let {
assetId,
assetSize,
loopVideo,
cacheKey,
playOriginalVideo,
@@ -115,9 +118,40 @@
let containerHeight = $state(0);
$effect(() => {
if (assetViewerManager.isFaceEditMode) {
videoPlayer?.pause();
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
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>
@@ -173,7 +207,7 @@
{/if}
{#if assetViewerManager.isFaceEditMode}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
<FaceEditor {assetSize} containerSize={{ width: containerWidth, height: containerHeight }} {assetId} />
{/if}
{/if}
</div>

View File

@@ -42,6 +42,7 @@
{loopVideo}
{cacheKey}
assetId={effectiveAssetId}
assetSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}

View File

@@ -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 { 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 { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -14,13 +14,12 @@
import { t } from 'svelte-i18n';
type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
assetSize: Size;
containerSize: Size;
assetId: string;
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let { assetSize, containerSize, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -54,7 +53,7 @@
};
const setupCanvas = () => {
if (!canvasEl || !htmlElement) {
if (!canvasEl) {
return;
}
@@ -86,24 +85,14 @@
searchInputEl?.focus();
});
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 imageContentMetrics = $derived(computeContentMetrics(assetSize, containerSize));
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics;
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
top: offsetY + contentHeight / 2 - 56,
left: offsetX + contentWidth / 2 - 56,
});
faceRect.setCoords();
@@ -116,8 +105,8 @@
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
width: containerSize.width,
height: containerSize.height,
});
if (!faceRect) {
@@ -167,6 +156,9 @@
const gap = 15;
const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const faceBox = {
left: rawBox.left - padding,
top: rawBox.top - padding,
@@ -175,11 +167,11 @@
};
const selectorWidth = faceSelectorEl.offsetWidth;
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 clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
@@ -238,45 +230,37 @@
});
const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) {
if (!faceRect || imageContentMetrics.contentWidth === 0) {
return;
}
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;
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, assetSize);
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
imageWidth: assetSize.width,
imageHeight: assetSize.height,
x: Math.floor(imageRect.left),
y: Math.floor(imageRect.top),
width: Math.floor(imageRect.width),
height: Math.floor(imageRect.height),
};
};
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) {
const imgRef = assetViewerManager.imgRef;
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return;
}
const natural = getNaturalSize(htmlElement);
if (natural.width <= 0 || natural.height <= 0) {
return;
}
const scaleX = imgRef.naturalWidth / assetSize.width;
const scaleY = imgRef.naturalHeight / assetSize.height;
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);
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;
@@ -292,7 +276,7 @@
}
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');
} catch {
return;

View File

@@ -1,18 +1,15 @@
import {
getContentMetrics,
computeContentMetrics,
getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent,
mapNormalizedToContent,
scaleToCover,
scaleToFit,
} from '$lib/utils/container-utils';
const mockImage = (props: {
naturalWidth: number;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
props as unknown as HTMLImageElement;
const mockVideo = (props: {
videoWidth: number;
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
});
});
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({
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({
contentWidth: 800,
contentHeight: 450,
offsetX: 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);
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 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 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 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);
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', () => {
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 });
});

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 => {
if (element instanceof HTMLVideoElement) {
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 };
};
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
const natural = getNaturalSize(element);
const client = getElementSize(element);
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
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(content, container);
return {
contentWidth,
contentHeight,
offsetX: (client.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2,
offsetX: (container.width - contentWidth) / 2,
offsetY: (container.height - contentHeight) / 2,
};
};
}
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
@@ -109,3 +103,25 @@ export function mapNormalizedRectToContent(
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,
};
}