From 6e8a600a8285bea2b80f23c246abfd643b642293 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 20:34:08 +0700 Subject: [PATCH] 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. --- docker/frontend/src/App.tsx | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 85f0269..1240199 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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