fix(web): don't stretch images

Lay out the viewer image from its natural, orientation-baked size instead of
asset.width/height, which can be wrong for some cameras (e.g. Sony orientation-8
JPEGs) and stretched portrait images into a landscape box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude (thor) 2026-08-10 17:59:00 -04:00
parent 0ff47f4178
commit 26fbf6459a
4 changed files with 106 additions and 10 deletions

View file

@ -0,0 +1,46 @@
import { AssetTypeEnum } from '@immich/sdk';
import { fireEvent, render } from '@testing-library/svelte';
import { tick } from 'svelte';
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
const setNaturalSize = (img: HTMLImageElement, width: number, height: number) => {
Object.defineProperties(img, {
naturalWidth: { value: width, configurable: true },
naturalHeight: { value: height, configurable: true },
});
};
const getDisplayBox = (baseElement: Element) =>
baseElement.querySelector<HTMLDivElement>('[style*="inset-inline-start"]');
const pixels = (value: string) => Number(value.replace('px', ''));
describe('AdaptiveImage', () => {
it('lays the image out with the aspect ratio of the loaded pixels, not the metadata dimensions', async () => {
const asset = assetFactory.build({ type: AssetTypeEnum.Image, width: 3872, height: 2592 });
const { baseElement } = render(AdaptiveImage, {
asset,
container: { width: 1000, height: 1000 },
});
const thumbnail = baseElement.querySelector<HTMLImageElement>('img[data-testid="thumbnail"]');
expect(thumbnail).not.toBeNull();
setNaturalSize(thumbnail!, 2592, 3872);
await fireEvent.load(thumbnail!);
await tick();
const box = getDisplayBox(baseElement);
expect(box).not.toBeNull();
const width = pixels(box!.style.width);
const height = pixels(box!.style.height);
expect(width).toBeLessThan(height);
});
});

View file

@ -58,7 +58,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
import { scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
import { resolveImageDimensions, scaleToCover, scaleToFit, type Size } from '$lib/utils/container-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
@ -81,7 +81,6 @@
let {
ref = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgRef = $bindable(),
asset,
sharedLink,
@ -136,19 +135,20 @@
);
});
let naturalSize = $state<Size>();
$effect.pre(() => {
const loader = adaptiveImageLoader;
untrack(() => assetViewerManager.resetZoomState());
untrack(() => {
assetViewerManager.resetZoomState();
naturalSize = undefined;
});
return () => loader.destroy();
});
const imageDimensions = $derived.by(() => {
const { width, height } = asset;
if (width && width > 0 && height && height > 0) {
return { width, height };
}
return { width: 1, height: 1 };
});
const imageDimensions = $derived(
resolveImageDimensions(naturalSize, { width: asset.width ?? 0, height: asset.height ?? 0 }),
);
const { insetInlineStart, top, displayWidth, displayHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
() => {
@ -216,6 +216,10 @@
(quality.original === 'success' ? originalElement : undefined) ??
(quality.preview === 'success' ? previewElement : undefined) ??
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
if (imgRef && imgRef.naturalWidth > 0 && imgRef.naturalHeight > 0) {
naturalSize = { width: imgRef.naturalWidth, height: imgRef.naturalHeight };
}
});
</script>

View file

@ -3,6 +3,7 @@ import {
getNaturalSize,
mapNormalizedRectToContent,
mapNormalizedToContent,
resolveImageDimensions,
scaleToCover,
scaleToFit,
} from '$lib/utils/container-utils';
@ -177,3 +178,36 @@ describe('mapNormalizedRectToContent', () => {
expect(rect).toEqual({ left: 200, top: 100, width: 400, height: 200 });
});
});
describe('resolveImageDimensions', () => {
it('should prefer the natural size when it is available', () => {
expect(resolveImageDimensions({ width: 2592, height: 3872 }, { width: 4000, height: 3000 })).toEqual({
width: 2592,
height: 3872,
});
});
it('should keep a portrait image portrait even when metadata claims landscape', () => {
const natural = { width: 2592, height: 3872 };
const metadata = { width: 3872, height: 2592 };
const resolved = resolveImageDimensions(natural, metadata);
expect(resolved.width).toBeLessThan(resolved.height);
expect(resolved).toEqual(natural);
});
it('should fall back to metadata dimensions before the image has loaded', () => {
expect(resolveImageDimensions(undefined, { width: 4000, height: 3000 })).toEqual({ width: 4000, height: 3000 });
});
it('should ignore a degenerate natural size and fall back to metadata', () => {
expect(resolveImageDimensions({ width: 0, height: 0 }, { width: 4000, height: 3000 })).toEqual({
width: 4000,
height: 3000,
});
});
it('should return a 1x1 square when neither size is valid', () => {
expect(resolveImageDimensions(undefined, { width: 0, height: 0 })).toEqual({ width: 1, height: 1 });
expect(resolveImageDimensions(undefined, undefined)).toEqual({ width: 1, height: 1 });
});
});

View file

@ -63,6 +63,18 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
return { width: element.naturalWidth, height: element.naturalHeight };
};
const isValidSize = (size: Size | undefined): size is Size => size !== undefined && size.width > 0 && size.height > 0;
export const resolveImageDimensions = (natural: Size | undefined, fallback: Size | undefined): Size => {
if (isValidSize(natural)) {
return natural;
}
if (isValidSize(fallback)) {
return fallback;
}
return { width: 1, height: 1 };
};
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
const natural = getNaturalSize(element);
const client = getElementSize(element);