diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 3166d60..64d4088 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -229,6 +229,12 @@ export function Workspace() { const [source, setSource] = useState(null); const [previewBytes, setPreviewBytes] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); + // COMPARE: a second render of the same frame through the neutral stock, so + // the split is a look against no-look and never a frame against a photo — + // rotation, straighten, crop and the frame all land on both halves by + // construction. Only asked for while the toggle is on. + const [comparing, setComparing] = useState(false); + const [compareUrl, setCompareUrl] = useState(null); const [recipe, setRecipe] = useState(() => ({ ...DEFAULT_RECIPES[0], @@ -520,37 +526,67 @@ export function Workspace() { }, [pro]); // --- preview render ------------------------------------------------------- + // A new photo has nothing to compare yet: the toggle goes off with it, so the + // compare copy is never a stale render of the last one. + useEffect(() => { + setComparing(false); + }, [source]); + useEffect(() => { if (!engine || !previewBytes) return; const token = ++renderToken.current; const timer = setTimeout(async () => { setBusy(t('act.exporting')); try { + // The geometry is the frame's own; only the recipe differs between the + // render and the compare copy, so the two can never drift apart. + const frame = cropEditing ? 'none' : frameId; + const options = { + wallframeLandscape: frameId === 'wallframe-landscape', + sharpen: true, + photoRotation: rotation, + photoStraighten: straighten, + cropRect: cropLive ? cropRect : null, + watermark: markOn && markText ? { text: markText, ...markStyle } : null, + gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, + }; const out = await renderPhoto({ source: previewBytes, recipe, // An uncommitted crop is framed on the bare photo, so the overlay // measures exactly what the engine will cut. - frameId: cropEditing ? 'none' : frameId, + frameId: frame, useGeotag, gpsInfo, assets: engine.assets, - options: { - wallframeLandscape: frameId === 'wallframe-landscape', - sharpen: true, - photoRotation: rotation, - photoStraighten: straighten, - cropRect: cropLive ? cropRect : null, - watermark: markOn && markText ? { text: markText, ...markStyle } : null, - gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, - }, + options, }); if (token !== renderToken.current) return; if (!out) throw new Error('render returned null'); - const url = URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' })); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); - return url; + return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' })); + }); + if (!comparing) { + setCompareUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + return; + } + const before = await renderPhoto({ + source: previewBytes, + recipe: BASE_RECIPE, + frameId: frame, + useGeotag, + gpsInfo, + assets: engine.assets, + options, + }); + if (token !== renderToken.current || !before) return; + setCompareUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return URL.createObjectURL(new Blob([before.bytes as BlobPart], { type: 'image/jpeg' })); }); } catch (err) { if (token === renderToken.current) setError(t('err.generic', { msg: String(err) })); @@ -560,7 +596,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle]); + }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -2461,7 +2497,11 @@ export function Workspace() { void saveOriginal()} onClear={() => setClearing(true)} picking={picking} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index cc1559b..1661be2 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -284,9 +284,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } cursor: ew-resize; touch-action: none; } -/* The window the original is looked at through: the render's own shape, so a - crop can slide and scale the photo behind it and still line up. */ -.compare-window { position: absolute; inset: 0; overflow: hidden; } .compare-orig { position: absolute; inset: 0; width: 100%; height: 100%; box-shadow: none; } .compare-bar { position: absolute; diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 0b97535..6fff445 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -65,9 +65,10 @@ export function ImageStage({ onCropRectChange, onStageZoom, canCompare = false, - // The crop the render is already carrying: the original is shown through the - // same window, so the two halves line up whatever the shape. - compareCrop = null, + // Compare is the app's: turning it on is what asks for the neutral render the + // left of the split shows, so the flag lives up there with the renderer. + compare = false, + onCompareChange, onSaveOriginal, // Takes the photo off the stage. What is on it goes first — the confirmation // offers to file the frame or export it, so nothing is lost by accident. @@ -96,11 +97,12 @@ export function ImageStage({ // 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; - // Compare puts the photo that was loaded beside the render, and the crop is - // part of that render: when one is applied the original is shown through the - // same window, so the split stays a straight line-up. + // Compare puts the photo the way it arrived beside the render — the app + // renders both from the same geometry, so the split is a straight line-up + // whatever the rotation, the straighten, the crop or the frame. canCompare?: boolean; - compareCrop?: CropRect | null; + compare?: boolean; + onCompareChange?: (on: boolean) => void; // Puts the untouched photo file itself in the owner's hands — on a phone that // is the share sheet, whose "Save Image" is what reaches the photo library. onSaveOriginal?: () => void; @@ -144,7 +146,6 @@ export function ImageStage({ const panRef = useRef<{ px: number; py: number } | null>(null); // Compare: the original on the left of the split, the render on the right. // 0.5 is the middle of the photo, which is where the bar starts. - const [compare, setCompare] = useState(false); const [split, setSplit] = useState(0.5); const draggingSplit = useRef(false); // The histogram overlay is shown by default and dismissed from its own frame @@ -205,15 +206,6 @@ export function ImageStage({ return () => window.removeEventListener('resize', measure); }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]); - // Compare is a property of the un-cropped photo, so a crop taking over (or a - // new photo arriving) puts it away. - useEffect(() => { - if (!canCompare) setCompare(false); - }, [canCompare]); - useEffect(() => { - setCompare(false); - }, [originalUrl]); - // Eyedropper. The decode is done on the click, not when PICK is armed: a // bitmap made up front would be stale after the first band move, and re-making // it on every render would decode a fresh JPEG per slider step. One bitmap is @@ -780,39 +772,17 @@ export function ImageStage({ onPointerCancel={endSplit} > {/* The original is clipped to everything left of the bar; the - graded render stays the un-clipped base underneath it. The - window is the render's own shape, so when a crop is applied the - original is scaled and shifted until that same rectangle fills - it — otherwise the split would compare a cropped render with a - whole photo. */} -
- -
+ /> { setSplit(0.5); - setCompare((v) => !v); + onCompareChange?.(!compare); }} > {t('drop.compare')}