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>