Size the preview copy to what the stage actually paints

The copy was fixed at 1600px, so a hidpi screen was already stretching it at
1:1 and every wheel notch made it worse. The stage now reports the size it is
painting at — the contain-fit times the pixel ratio times the zoom — and the
app re-cuts the source copy to match, quantised and capped at 3200px, which is
the largest copy the grade can still afford. The wheel keeps its instant
transform; the sharper copy lands once the gesture stops.
This commit is contained in:
2026-09-17 21:17:54 +07:00
parent 8747b1686b
commit 99be7e96bd
2 changed files with 48 additions and 12 deletions
+26 -9
View File
@@ -33,9 +33,13 @@ const PREVIEW_MAX = 1600;
// A committed crop keeps only a fraction of the photo, so the 1600px copy
// leaves too few pixels behind and the result reads as blocky once the stage
// scales it up. While a crop is live the source is re-derived larger, inverted
// by that fraction, and capped here so the decode stays bounded.
const CROP_PREVIEW_MAX = 3200;
// by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is
// what the decode can afford: past it a wheel gesture stops keeping up.
const PREVIEW_HARD_MAX = 3200;
const CROP_PREVIEW_MIN_FRAC = 0.05;
// Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it
// on every notch.
const PREVIEW_QUANTUM = 512;
const PREVIEW_DEBOUNCE_MS = 120;
const FRAMES: { id: FrameId; label: MsgKey }[] = [
@@ -169,6 +173,13 @@ export function Workspace() {
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
// and the whole photo is shown), and applied (the crop alone is rendered).
// What the stage says it is painting at; PREVIEW_MAX until it reports.
const [zoomNeedPx, setZoomNeedPx] = useState(PREVIEW_MAX);
const onStageZoom = useCallback((longDevicePx: number) => {
setZoomNeedPx(
Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM))
);
}, []);
const cropEditing = crop !== 'none' && !cropApplied;
const cropLive = crop !== 'none' && cropApplied;
const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null;
@@ -187,12 +198,15 @@ export function Workspace() {
const long = Math.max(d?.w ?? 0, d?.h ?? 0);
return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h);
})();
const previewMax = cropLive
? Math.min(
CROP_PREVIEW_MAX,
Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
)
: PREVIEW_MAX;
const previewMax = Math.min(
PREVIEW_HARD_MAX,
Math.max(
zoomNeedPx,
cropLive
? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
: PREVIEW_MAX
)
);
// --- undo -----------------------------------------------------------------
// The stack holds whole looks. remember() reads a ref written during render,
@@ -375,8 +389,10 @@ export function Workspace() {
if (prev) URL.revokeObjectURL(prev.url);
return { bytes, name, url: URL.createObjectURL(blob) };
});
// The new photo's copy is the 1600px one; a live crop then upgrades it.
// The new photo's copy is the 1600px one; a live crop or a zoomed stage
// then upgrades it.
previewMaxRef.current = PREVIEW_MAX;
setZoomNeedPx(PREVIEW_MAX);
previewJobRef.current++;
setPreviewBytes(preview);
setGps(found);
@@ -1155,6 +1171,7 @@ export function Workspace() {
cropRect={cropRect}
cropWH={cropWH}
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
/>
</div>
+22 -3
View File
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { MIN_CROP_FRAC, type CropRect } from '../../shared/types';
// One wheel notch, and how far the photo may be pulled open. Past 1 the render
// is the 1600px copy, so deep zoom is soft by design — it is for reading
// framing, not for judging grain.
// One wheel notch, and how far the photo may be pulled open.
const ZOOM_STEP = 1.15;
const ZOOM_MAX = 8;
// How long the wheel has to rest before the stage asks for a sharper copy.
const ZOOM_SETTLE_MS = 160;
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
@@ -23,6 +23,7 @@ export function ImageStage({
cropRect,
cropWH = null,
onCropRectChange,
onStageZoom,
}: {
previewUrl: string | null;
originalUrl: string | null;
@@ -37,6 +38,10 @@ export function ImageStage({
// Wanted pixel aspect (w/h) for a fixed ratio, null for FREE.
cropWH?: number | null;
onCropRectChange?: (r: CropRect) => void;
// Longest side, in device pixels, the stage is actually painting the photo
// at — the app re-cuts its preview copy to match. Scaled to contain the
// photo and multiplied by the zoom and the screen's pixel ratio.
onStageZoom?: (longDevicePx: number) => void;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
@@ -201,6 +206,20 @@ export function ImageStage({
return () => window.removeEventListener('resize', onResize);
}, [clampPan]);
// Report the size the stage is painting at so the app can re-cut the preview
// copy to match — otherwise a retina screen already stretches it at 1:1 and
// zooming only makes that worse. Debounced: the wheel keeps its instant
// transform and the sharper copy lands once the gesture stops.
useEffect(() => {
if (!onStageZoom) return;
const timer = setTimeout(() => {
const b = baseRect();
if (!b) return;
onStageZoom(Math.round(Math.max(b.w, b.h) * (window.devicePixelRatio || 1) * viewRef.current.s));
}, ZOOM_SETTLE_MS);
return () => clearTimeout(timer);
}, [onStageZoom, baseRect, view.s, previewUrl, cropEditing]);
const startPan = (e: React.PointerEvent<HTMLImageElement>) => {
if (cropEditing || viewRef.current.s <= 1 || e.button !== 0) return;
e.preventDefault();