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.
This commit is contained in:
2026-09-25 08:46:27 +07:00
parent 9d7a5beec2
commit c70edce8c1
5 changed files with 185 additions and 27 deletions
+86 -20
View File
@@ -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<string | null>(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<Look | null>(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<SavedRecipe[]>([]);
// 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',
+14 -3
View File
@@ -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<RenderResult | nu
own(skImage0);
let skImage = skImage0;
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
const rotated = applyPhotoRotation(
skImage,
options?.photoRotation,
options?.photoStraighten,
options?.photoFlipH,
options?.photoFlipV
);
if (rotated && rotated !== skImage) {
own(rotated);
release(owned, skImage);
@@ -913,9 +923,10 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
// A phone's GPS carries no place name, and neither does a photo's EXIF,
// so the coordinates are what the stamp prints — the old literal
// 'STREET VIEW' placeholder read as if that were the real location.
const { latitude, longitude } = input.gpsInfo;
const locationName =
input.gpsInfo.locality?.trim() ||
`${input.gpsInfo.latitude.toFixed(5)}, ${input.gpsInfo.longitude.toFixed(5)}`;
(Number.isFinite(latitude) ? `${latitude.toFixed(5)}, ${longitude.toFixed(5)}` : '');
const d = new Date(input.gpsInfo.timestamp);
const pad = (n: number) => 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<RenderResult | nu
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
const lines: { icon: string; text: string }[] = [];
if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
if (gpsPos?.showName !== false && locationName) lines.push({ icon: '📍', text: locationName });
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
const wmRot = options?.watermarkRotation ?? 0;
+3
View File
@@ -22,6 +22,9 @@ export interface StudioState {
rotation?: 0 | 90 | 180 | 270;
// FRAME's STRAIGHTEN: degrees off the quarter turn, -180..180.
straighten?: number;
// FRAME's two mirrors. Absent in older sessions -> no flip.
flipH?: boolean;
flipV?: boolean;
gps?: GPSInfo | null;
gpsManual?: { lat: string; lng: string };
place?: string;