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