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; }