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}
|
||||
|
||||
Reference in New Issue
Block a user