From c70edce8c12d63155e08a95e946625dc016ab327 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 25 Sep 2026 08:46:27 +0700 Subject: [PATCH] web: mirror the frame with H-FLIP and V-FLIP, and stamp a typed place ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and stay on through the quarter turns and STRAIGHTEN, which makes them compose with every rotation the strip already offers. ROTATE's own RESET levels the whole frame, mirrors included. The flip itself lands last, in screen space, so a mirrored photo is what the eye sees rather than what the sensor saw; the pixels are copied axis-aligned, so there is nothing to resample. Session state carries the two flags, so a reopened photo comes back mirrored. Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its own (latitude/longitude ride in as NaN), instead of the whole stamp and its box being skipped for want of coordinates. --- docker/frontend/shared/utils/skiaImage.ts | 43 ++++++++- docker/frontend/src/App.tsx | 106 +++++++++++++++++---- docker/frontend/src/engine/exportEngine.ts | 17 +++- docker/frontend/src/engine/session.ts | 3 + src/utils/skiaImage.ts | 43 ++++++++- 5 files changed, 185 insertions(+), 27 deletions(-) diff --git a/docker/frontend/shared/utils/skiaImage.ts b/docker/frontend/shared/utils/skiaImage.ts index 2883e18..1735df3 100644 --- a/docker/frontend/shared/utils/skiaImage.ts +++ b/docker/frontend/shared/utils/skiaImage.ts @@ -76,13 +76,47 @@ export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage } } +// Mirror the photo left<->right (H-FLIP) and/or top<->bottom (V-FLIP). An +// axis-aligned mirror lands whole pixels on whole pixels, so the plain +// drawImage stays exact: no sampling option needed the way STRAIGHTEN's rotated +// edge does. The dimensions never change, so the crop stays valid. +// ponytail: one offscreen surface per call, same trade as rotateSkImage90. +export function flipSkImage( + image: SkImage | null, + horizontal: boolean, + vertical: boolean +): SkImage | null { + if (!image || (!horizontal && !vertical)) return image; + const w = image.width(); + const h = image.height(); + if (!(w > 0 && h > 0)) return null; + const surface = Skia.Surface.Make(w, h); + if (!surface) return image; // keep the unflipped photo on allocation failure + try { + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + // A negative scale turns every pixel coordinate around, so the translate + // puts the mirrored copy back inside the surface. + canvas.scale(horizontal ? -1 : 1, vertical ? -1 : 1); + canvas.translate(horizontal ? -w : 0, vertical ? -h : 0); + canvas.drawImage(image, 0, 0); + return surface.makeImageSnapshot() ?? image; + } finally { + surface.dispose(); // see rotateSkImage90 + } +} + // The whole FRAME-tab photo orientation in one call: the quarter turn first // (rotateSkImage90 swaps the pixel dimensions, which then propagates through -// every consumer unchanged), then the fine STRAIGHTEN on top of it. +// every consumer unchanged), then the fine STRAIGHTEN on top of it, then the +// two mirrors — a flip is what the user sees, so it mirrors the photo as it +// stands, whatever turn and angle are already on it. export function applyPhotoRotation( image: SkImage | null, quarter = 0, - straighten = 0 + straighten = 0, + flipH = false, + flipV = false ): SkImage | null { let out = image; const turns = Math.round(quarter / 90) % 4; @@ -98,5 +132,10 @@ export function applyPhotoRotation( if (s && s !== out && out !== image) out.dispose(); // see above out = s ?? out; } + if (flipH || flipV) { + const f = flipSkImage(out, flipH, flipV); + if (f && f !== out && out !== image) out.dispose(); // see above + out = f ?? out; + } return out; } diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index f6c306d..27644a0 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -249,6 +249,8 @@ interface Look { cropApplied: boolean; rotation: 0 | 90 | 180 | 270; straighten: number; + flipH: boolean; + flipV: boolean; markOn: boolean; markText: string; markStyle: MarkStyle; @@ -294,6 +296,11 @@ export function Workspace() { // FRAME's STRAIGHTEN: a fine angle on top of the quarter turn. Same pixels, // so the photo's own dimensions never change and the crop stays valid. const [straighten, setStraighten] = useState(() => session.straighten ?? 0); + // FRAME's two mirrors: left<->right and top<->bottom. Same pixels, so like + // STRAIGHTEN they leave the dimensions alone and the crop stays valid — and + // they ride on top of the quarter turn and the angle instead of replacing it. + const [flipH, setFlipH] = useState(() => session.flipH ?? false); + const [flipV, setFlipV] = useState(() => session.flipV ?? false); const [openParam, setOpenParam] = useState(null); // The HSL mixer: which hue band the ruler edits, whether the eyedropper is // armed, and the last colour it read off the photo. @@ -359,8 +366,8 @@ export function Workspace() { // the session. Small JSON, so no debounce is worth the risk of losing the last // edit; the photo has its own store (engine/session.ts). useEffect(() => { - saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); - }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); + saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); + }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); // Crop has three states: none, framed-but-uncommitted (the frame is draggable // and the whole photo is shown), and applied (the crop alone is rendered). @@ -437,7 +444,7 @@ export function Workspace() { // back returns that filter — and its sim name — instead of guessing PROVIA. const monoPrev = useRef<{ baseFilter: Recipe['baseFilter']; simId: string | null; name: string } | null>(null); const lookRef = useRef(null); - lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle }; + lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsStyle }; // Twenty looks is enough to walk, but the oldest entry is the floor — the look // the session loaded with — and is never the one trimmed, so UNDO always // reaches the start of the session. @@ -462,6 +469,8 @@ export function Workspace() { setCropApplied(l.cropApplied); setRotation(l.rotation); setStraighten(l.straighten); + setFlipH(l.flipH); + setFlipV(l.flipV); setMarkOn(l.markOn); setMarkText(l.markText); setMarkStyle(l.markStyle); @@ -481,6 +490,8 @@ export function Workspace() { cropApplied: false, rotation: 0, straighten: 0, + flipH: false, + flipV: false, markOn: false, markText: '', markStyle: { ...DEFAULT_MARK_STYLE }, @@ -510,7 +521,20 @@ export function Workspace() { // fresh one on every render would re-arm the render effect with its own // output — the stage would repaint itself forever and the panel would stop // answering clicks. - const gpsInfo = useMemo(() => (gps ? { ...gps, locality: place.trim() || gps.locality } : null), [gps, place]); + // A typed PLACE NAME stands on its own: a photo that carried no GPS still + // stamps the name (with the photo's own date), so the switch never sits ON + // over a photo with nothing drawn on it. NaN coordinates are what keeps the + // "print the fix" fallback off — the stamp, gpsLines and the box all test + // them — while locality carries the line. + const gpsInfo = useMemo( + () => + gps + ? { ...gps, locality: place.trim() || gps.locality } + : place.trim() + ? { latitude: NaN, longitude: NaN, locality: place.trim(), timestamp: capturedAt ?? Date.now() } + : null, + [gps, place, capturedAt] + ); const [saved, setSaved] = useState([]); // Bumped by RESET while CREATE is open: the form above the button listens for // it and drops back to its defaults. @@ -631,9 +655,11 @@ export function Workspace() { sharpen: true, photoRotation: rotation, photoStraighten: straighten, + photoFlipH: flipH, + photoFlipV: flipV, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, ...markStyle } : null, - gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, + gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null, }; const out = await renderPhoto({ source: previewBytes, @@ -681,7 +707,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, comparing]); + }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, 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. @@ -1485,13 +1511,15 @@ export function Workspace() { sharpen: true, photoRotation: rotation, photoStraighten: straighten, + photoFlipH: flipH, + photoFlipV: flipV, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, ...markStyle } : tier.watermark ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } : null, - gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, + gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); @@ -1524,7 +1552,7 @@ export function Workspace() { // The upscale's own progress line goes with the export that wrote it. setBusy(null); } - }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]); // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its @@ -1577,9 +1605,11 @@ export function Workspace() { sharpen: true, photoRotation: rotation, photoStraighten: straighten, + photoFlipH: flipH, + photoFlipV: flipV, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, ...markStyle } : null, - gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, + gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); @@ -1599,6 +1629,8 @@ export function Workspace() { sharpen: false, photoRotation: rotation, photoStraighten: straighten, + photoFlipH: flipH, + photoFlipV: flipV, cropRect: cropLive ? cropRect : null, }, }); @@ -1638,7 +1670,7 @@ export function Workspace() { } finally { setSavingPhoto(false); } - }, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); + }, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, strip, photoSettings, t]); // CLEAR's "keep it" answers: the save lands and the photo goes, or the export // hands a file over and then it goes. Neither forces the hand — a save that @@ -2050,8 +2082,12 @@ export function Workspace() { onPick: (v) => { remember(); setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)); - // RESET levels the whole frame, angle included. - if (v === 'reset') setStraighten(0); + // RESET levels the whole frame, angle and mirrors included. + if (v === 'reset') { + setStraighten(0); + setFlipH(false); + setFlipV(false); + } }, }, style: { @@ -2248,6 +2284,7 @@ export function Workspace() { || Object.keys(a.hslBands ?? {}).length > 0 || (simId ?? 'sim-provia') !== 'sim-provia' || frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0 + || flipH || flipV || markOn || markText !== '' || markStyle.color !== DEFAULT_MARK_STYLE.color || markStyle.size !== DEFAULT_MARK_STYLE.size || markStyle.x !== DEFAULT_MARK_STYLE.x || markStyle.y !== DEFAULT_MARK_STYLE.y @@ -2257,7 +2294,7 @@ export function Workspace() { || masks.length > 0 || useGeotag || (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]); - }, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]); + }, [recipe, simId, frameId, crop, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsStyle]); // The chosen mask's knobs, as the app's own rulers: one parameter to a row, // the same row every other number in the app is edited with. EXPOSURE is the @@ -2371,13 +2408,17 @@ export function Workspace() { })), groupChip('crop'), // The chip names whichever of the two is off the straight — and both when - // both are. + // both are — then the mirrors riding on top of them. { key: 'grp-rotate', label: 'ROTATE', - value: straighten !== 0 ? `${rotation}° ${straighten > 0 ? '+' : ''}${straighten}°` : `${rotation}°`, + value: [ + straighten !== 0 ? `${rotation}° ${straighten > 0 ? '+' : ''}${straighten}°` : `${rotation}°`, + ...(flipH ? ['H-FLIP'] : []), + ...(flipV ? ['V-FLIP'] : []), + ].join(' '), active: openGroup === 'rotate', - amberValue: openGroup !== 'rotate' && (rotation !== 0 || straighten !== 0), + amberValue: openGroup !== 'rotate' && (rotation !== 0 || straighten !== 0 || flipH || flipV), onClick: () => toggleGroup('rotate'), }, { @@ -2674,6 +2715,27 @@ export function Workspace() { // picking a value: the quarter turns are choices, the angle is a knob. ...(g === 'rotate' ? [ + // The two mirrors are switches, not picks: each stays on whatever the + // quarter turn and the angle are, so a flip is one tap away from any + // angle the strip can hold. + { + key: 'flip-h', + label: 'H-FLIP', + active: flipH, + onClick: () => { + remember(); + setFlipH((v) => !v); + }, + }, + { + key: 'flip-v', + label: 'V-FLIP', + active: flipV, + onClick: () => { + remember(); + setFlipV((v) => !v); + }, + }, { key: 'straighten', label: 'STRAIGHTEN', @@ -2728,10 +2790,14 @@ export function Workspace() { const gpsLines = useMemo(() => { if (!gpsInfo) return []; const out: string[] = []; + // The coordinates are only a fallback for a fix that carries no name, and a + // photo whose name was typed in has no coordinates to print. + const coords = Number.isFinite(gpsInfo.latitude) + ? `${gpsInfo.latitude.toFixed(5)}, ${gpsInfo.longitude.toFixed(5)}` + : ''; if (gpsStyle.showName) { - out.push( - gpsInfo.locality?.trim() || `${gpsInfo.latitude.toFixed(5)}, ${gpsInfo.longitude.toFixed(5)}` - ); + const name = gpsInfo.locality?.trim() || coords; + if (name) out.push(name); } if (gpsStyle.showTime) { const d = new Date(gpsInfo.timestamp); @@ -3136,7 +3202,7 @@ export function Workspace() { }, ] : []), - ...(useGeotag && gps && gpsLines.length + ...(useGeotag && gpsLines.length ? [ { key: 'gps', diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index ebb8320..ac21c05 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -84,6 +84,10 @@ export interface RenderOptions { frameWindowZoom?: { s: number; u: number; v: number } | null; photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; + // FRAME-tab mirrors: left<->right and top<->bottom, each independent of the + // quarter turn and the fine angle (they ride on top of both). + photoFlipH?: boolean; + photoFlipV?: boolean; // The strip the FILE carries: the amber `#TAG` over the photo and a caption // band appended below it, mirroring the landing page's `.lp-shot` + // `.lp-frame-meta`. Left off the live preview, which must not show it. @@ -371,7 +375,13 @@ export async function renderPhoto(input: RenderInput): Promise String(n).padStart(2, '0'); const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; @@ -928,7 +939,7 @@ export async function renderPhoto(input: RenderInput): Promise no flip. + flipH?: boolean; + flipV?: boolean; gps?: GPSInfo | null; gpsManual?: { lat: string; lng: string }; place?: string; diff --git a/src/utils/skiaImage.ts b/src/utils/skiaImage.ts index 03a69ab..292d5da 100644 --- a/src/utils/skiaImage.ts +++ b/src/utils/skiaImage.ts @@ -63,13 +63,47 @@ export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage } } +// Mirror the photo left<->right (H-FLIP) and/or top<->bottom (V-FLIP). An +// axis-aligned mirror lands whole pixels on whole pixels, so the plain +// drawImage stays exact: no sampling option needed the way STRAIGHTEN's rotated +// edge does. The dimensions never change, so the crop stays valid. +// ponytail: one offscreen surface per call, same trade as rotateSkImage90. +export function flipSkImage( + image: SkImage | null, + horizontal: boolean, + vertical: boolean +): SkImage | null { + if (!image || (!horizontal && !vertical)) return image; + const w = image.width(); + const h = image.height(); + if (!(w > 0 && h > 0)) return null; + const surface = Skia.Surface.Make(w, h); + if (!surface) return image; // keep the unflipped photo on allocation failure + try { + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + // A negative scale turns every pixel coordinate around, so the translate + // puts the mirrored copy back inside the surface. + canvas.scale(horizontal ? -1 : 1, vertical ? -1 : 1); + canvas.translate(horizontal ? -w : 0, vertical ? -h : 0); + canvas.drawImage(image, 0, 0); + return surface.makeImageSnapshot() ?? image; + } finally { + surface.dispose(); // see rotateSkImage90 + } +} + // The whole FRAME-tab photo orientation in one call: the quarter turn first // (rotateSkImage90 swaps the pixel dimensions, which then propagates through -// every consumer unchanged), then the fine STRAIGHTEN on top of it. +// every consumer unchanged), then the fine STRAIGHTEN on top of it, then the +// two mirrors — a flip is what the user sees, so it mirrors the photo as it +// stands, whatever turn and angle are already on it. export function applyPhotoRotation( image: SkImage | null, quarter = 0, - straighten = 0 + straighten = 0, + flipH = false, + flipV = false ): SkImage | null { let out = image; const turns = Math.round(quarter / 90) % 4; @@ -85,5 +119,10 @@ export function applyPhotoRotation( if (s && s !== out && out !== image) out.dispose(); // see above out = s ?? out; } + if (flipH || flipV) { + const f = flipSkImage(out, flipH, flipV); + if (f && f !== out && out !== image) out.dispose(); // see above + out = f ?? out; + } return out; }