web: compare the same frame rendered twice, whatever its geometry
The split used to paint the photo file beside the render, so it only lined up while nothing had moved: a turn, a straighten, a printed frame and the halves were two different pictures. The app now renders the same frame twice — once through the look, once through the neutral stock — and the left of the bar is that second copy. Rotation, straighten, crop and frame land on both halves by construction, so the CSS that tried to map the crop onto the file goes away. The toggle lives in the app now, which is what knows how to ask for the extra render; it is only asked for while the split is up. The layer waits for that copy rather than flashing the raw file, whose geometry is already wrong.
This commit is contained in:
+54
-15
@@ -229,6 +229,12 @@ export function Workspace() {
|
||||
const [source, setSource] = useState<LoadedSource | null>(null);
|
||||
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(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<string | null>(null);
|
||||
|
||||
const [recipe, setRecipe] = useState<Recipe>(() => ({
|
||||
...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() {
|
||||
|
||||
<ImageStage
|
||||
previewUrl={previewUrl}
|
||||
originalUrl={source?.url ?? null}
|
||||
// Nothing to show until the neutral render lands: the file itself is
|
||||
// the wrong geometry the moment the photo has been turned or cropped.
|
||||
originalUrl={comparing ? compareUrl : source?.url ?? null}
|
||||
compare={comparing}
|
||||
onCompareChange={setComparing}
|
||||
busy={status}
|
||||
error={error}
|
||||
peek={peek}
|
||||
@@ -2473,7 +2513,6 @@ export function Workspace() {
|
||||
onCropRectChange={setCropRect}
|
||||
onStageZoom={onStageZoom}
|
||||
canCompare={!!source}
|
||||
compareCrop={cropLive ? cropRect : null}
|
||||
onSaveOriginal={() => void saveOriginal()}
|
||||
onClear={() => setClearing(true)}
|
||||
picking={picking}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */}
|
||||
<div
|
||||
className="compare-window"
|
||||
graded render stays the un-clipped base underneath it. Both
|
||||
copies come out of the same pass through the engine, so the
|
||||
clip is the whole geometry — the two line up on their own. */}
|
||||
<img
|
||||
className="compare-orig"
|
||||
data-key="compare-orig"
|
||||
src={originalUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{ clipPath: `inset(0 ${(1 - split) * 100}% 0 0)` }}
|
||||
>
|
||||
<img
|
||||
className="compare-orig"
|
||||
data-key="compare-orig"
|
||||
src={originalUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={
|
||||
compareCrop
|
||||
? {
|
||||
left: `${(-compareCrop.x / compareCrop.w) * 100}%`,
|
||||
top: `${(-compareCrop.y / compareCrop.h) * 100}%`,
|
||||
width: `${100 / compareCrop.w}%`,
|
||||
height: `${100 / compareCrop.h}%`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
// .canvas-wrap img clamps to the box; the original has
|
||||
// to be free to overflow it so the crop can slide.
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
<span
|
||||
className="compare-bar"
|
||||
data-key="compare-bar"
|
||||
@@ -891,7 +861,7 @@ export function ImageStage({
|
||||
disabled={!canCompare}
|
||||
onClick={() => {
|
||||
setSplit(0.5);
|
||||
setCompare((v) => !v);
|
||||
onCompareChange?.(!compare);
|
||||
}}
|
||||
>
|
||||
{t('drop.compare')}
|
||||
|
||||
Reference in New Issue
Block a user