fix(capture): preserve PhotoStage preview aspect on first paint

Contain-fit waits for a real parent width, uses ResizeObserver, and shrinks
width when maxH would otherwise stretch the CSS box. Pure fitDisplay is
covered by node tests.
This commit is contained in:
2026-09-14 16:37:00 +00:00
parent 0e94b1fd2a
commit b781ffeb66
6 changed files with 568 additions and 21 deletions
+44 -19
View File
@@ -1,40 +1,54 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import type { BBox } from './capture.types';
import { fitDisplay } from './fitDisplay';
// Display-only: renders the photo fitted to device width (height capped) plus
// one overlay layer. All math lives on source pixels, not this canvas.
export let blob: Blob;
export let maxH = 420;
export let bbox: BBox | null = null;
export let heading: 1 | -1 = 1;
export let maskBitset: Uint8Array | null = null;
export let maskW = 0;
export let maskH = 0;
let url: string | null = null;
let canvas: HTMLCanvasElement | undefined;
let stage: HTMLDivElement | undefined;
let imgW = 0, imgH = 0; // source px, learned once per blob
let parentW = 0;
const setBlobUrl = (b: Blob) => {
if (url) URL.revokeObjectURL(url);
url = URL.createObjectURL(b);
};
$: if (blob) setBlobUrl(blob);
$: if (blob) {
setBlobUrl(blob);
imgW = 0;
}
const applyFit = () => {
if (!canvas || !imgW) return;
if (parentW <= 0 && stage) parentW = stage.clientWidth;
const fit = fitDisplay(parentW, imgW, imgH, maxH);
if (!fit) return;
canvas.style.width = `${fit.fitW}px`;
canvas.style.height = `${fit.fitH}px`;
};
const redraw = () => {
if (!url || !canvas) return;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const img = new Image();
img.onload = () => {
if (!canvas) return;
const fitW = canvas.parentElement?.clientWidth || canvas.clientWidth || 320;
const fitH = Math.min(maxH, (img.height / img.width) * fitW);
canvas.width = img.width;
canvas.height = img.height;
canvas.style.width = `${fitW}px`;
canvas.style.height = `${fitH}px`;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
imgW = img.width;
imgH = img.height;
canvas.width = imgW;
canvas.height = imgH;
applyFit();
ctx.drawImage(img, 0, 0);
if (maskBitset && maskW > 0 && maskH > 0) {
const off = document.createElement('canvas');
@@ -66,8 +80,9 @@
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
ctx.closePath();
ctx.stroke();
const a = heading === 1 ? c[1] : c[0];
const b = heading === 1 ? c[0] : c[1];
// The axis reads corners[3] → corners[0], the long side.
const a = c[3];
const b = c[0];
ctx.strokeStyle = '#ff3d71';
ctx.lineWidth = Math.max(3, canvas.width / 300);
ctx.beginPath();
@@ -88,17 +103,27 @@
img.src = url;
};
$: if (url || bbox || heading || maskBitset) void tick().then(redraw);
$: if (url || bbox || maskBitset) void tick().then(redraw);
const onResize = () => redraw();
if (typeof window !== 'undefined') window.addEventListener('resize', onResize);
onMount(() => {
if (stage) parentW = stage.clientWidth;
const ro = new ResizeObserver((entries) => {
const e = entries[0];
if (!e) return;
const w = e.contentRect.width;
if (w === parentW) return;
parentW = w;
applyFit();
});
if (stage) ro.observe(stage);
return () => ro.disconnect();
});
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('resize', onResize);
if (url) URL.revokeObjectURL(url);
});
</script>
<div class="stage">
<div class="stage" bind:this={stage}>
{#if url}
<canvas bind:this={canvas}></canvas>
{:else}
@@ -108,6 +133,6 @@
<style>
.stage { width: 100%; display: flex; justify-content: center; }
canvas { max-width: 100%; border-radius: 8px; background: #222; }
canvas { display: block; border-radius: 8px; background: #222; }
.loading { padding: 40px; color: #999; }
</style>
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fitDisplay } from './fitDisplay.ts';
test('parentW=0 → null (no size until laid out)', () => {
assert.equal(fitDisplay(0, 1000, 750, 420), null);
assert.equal(fitDisplay(-5, 1000, 750, 420), null);
});
test('280 wide, 4:3 landscape → contain, under the height cap', () => {
const fit = fitDisplay(280, 1600, 1200, 420);
assert.ok(fit);
assert.equal(fit.fitW, 280);
assert.ok(Math.abs(fit.fitH - 210) < 1e-9);
assert.ok(fit.fitH <= 420);
});
test('portrait under maxH never stretches: height capped, width shrinks', () => {
// 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420.
const fit = fitDisplay(400, 1000, 3000, 420);
assert.ok(fit);
assert.equal(fit.fitH, 420);
assert.ok(Math.abs(fit.fitW - 140) < 1e-9);
// aspect preserved: fitW/fitH === imgW/imgH
assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9);
});
+17
View File
@@ -0,0 +1,17 @@
// Contain-fit of an image into a parent width with a height cap. Pure and
// display-only; all measurement math stays on source pixels.
// Null when the parent is not laid out yet (clientWidth 0) — the caller must
// not finalize CSS size until it is, to avoid a wrong-aspect flash.
export function fitDisplay(
parentW: number, imgW: number, imgH: number, maxH: number,
): { fitW: number; fitH: number } | null {
if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null;
const fitW = parentW;
let fitH = (imgH / imgW) * fitW;
if (fitH > maxH) {
// Cap height and shrink width to preserve aspect; never stretch.
fitH = maxH;
return { fitW: (fitH * imgW) / imgH, fitH };
}
return { fitW, fitH };
}