Re-derive the preview source at the crop's own resolution

A committed crop kept only its share of the 1600px whole-photo copy, so the
stage then showed a 2x upscale and the photo read as broken. While a crop is
live the source is remade 1/f larger (f = the crop's longest side as a
fraction of the photo's), capped so the decode stays bounded; UNDO/CANCEL
and a new photo put the 1600px copy back.
This commit is contained in:
2026-09-17 20:34:08 +07:00
parent 751beb52c1
commit 6e8a600a82
+48
View File
@@ -30,6 +30,12 @@ import type { MsgKey } from './i18n/vi';
// Preview renders on a downscaled copy; export always re-renders the original
// bytes, so nothing shown here is baked into the file.
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;
const CROP_PREVIEW_MIN_FRAC = 0.05;
const PREVIEW_DEBOUNCE_MS = 120;
const FRAMES: { id: FrameId; label: MsgKey }[] = [
@@ -166,6 +172,27 @@ export function Workspace() {
const cropEditing = crop !== 'none' && !cropApplied;
const cropLive = crop !== 'none' && cropApplied;
const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null;
// A committed crop keeps only part of the photo, so the 1600px copy leaves
// too few pixels behind and the stage shows a blocky upscale. While the crop
// is live the source is re-derived larger — 1/f, where f is the crop's
// longest side as a fraction of the photo's, capped so the decode stays
// bounded. cropRect is in the rotated frame, so the shape is swapped first.
const cropLongFrac = (() => {
const d = photoDims;
const turn = rotation % 180 !== 0;
const f = Math.max(
(turn ? d?.h ?? 0 : d?.w ?? 0) * cropRect.w,
(turn ? d?.w ?? 0 : d?.h ?? 0) * cropRect.h
);
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;
// --- undo -----------------------------------------------------------------
// The stack holds whole looks. remember() reads a ref written during render,
@@ -224,6 +251,10 @@ export function Workspace() {
(new URLSearchParams(window.location.search).has('auth') ? 'signup' : null));
const renderToken = useRef(0);
// Which previewMax the bytes in previewBytes were made at, so the upgrade
// effect does not redo the load-time 1600px copy.
const previewMaxRef = useRef(PREVIEW_MAX);
const previewJobRef = useRef(0);
// --- boot -----------------------------------------------------------------
useEffect(() => {
@@ -308,6 +339,20 @@ export function Workspace() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]);
// The source copy behind previewBytes, re-made only when previewMax moves:
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
useEffect(() => {
if (!source || previewMaxRef.current === previewMax) return;
const job = ++previewJobRef.current;
resizedJpeg(source.bytes, previewMax)
.then((bytes) => {
if (job !== previewJobRef.current) return;
previewMaxRef.current = previewMax;
setPreviewBytes(bytes);
})
.catch(() => undefined);
}, [source, previewMax]);
// --- input ---------------------------------------------------------------
// One photo, two ways in: a drop/pick (which also stores it for the next
// visit) and the session restore, whose params are already in state and so
@@ -330,6 +375,9 @@ 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.
previewMaxRef.current = PREVIEW_MAX;
previewJobRef.current++;
setPreviewBytes(preview);
setGps(found);
// A new photo starts a new history whose floor is the default look, so