From 11ed786758f2dfb1b5eff812919ecbd416b97056 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 10 Sep 2026 16:35:35 +0700 Subject: [PATCH] Let the GPS watermark be placed, sized and hung landscape Three WATERMARK/FRAME behaviours that share the stamp geometry: * GPS mark: drag it anywhere on the frame/photo area and pinch it bigger or smaller. The 0..1 anchor plus the size multiplier ride along in the export options, so a framed file (whose canvas IS the card/frame) burns the mark in at exactly the fraction the preview showed. * The photo's own pinch zoom stays available while the WATERMARK editor is closed, so a two-finger zoom is never swallowed by the mark while its panel is open. * WALL FRAME gains a WALL PORTRAIT / WALL LANDSCAPE chip: the artwork hangs in its own 4000x3117 orientation instead of the default 90-degree rotation. --- App.tsx | 23 ++- src/components/AdjustmentPanel.tsx | 13 ++ src/components/Viewfinder.tsx | 266 +++++++++++++++++++++-------- src/types/index.ts | 10 ++ src/utils/exportEngine.ts | 73 ++++---- src/utils/frameUtils.ts | 44 +++-- 6 files changed, 313 insertions(+), 116 deletions(-) diff --git a/App.tsx b/App.tsx index 5931ad0..88189d1 100644 --- a/App.tsx +++ b/App.tsx @@ -16,7 +16,7 @@ import ToolRail, { TabId } from './src/components/ToolRail'; import SettingsModal from './src/components/SettingsModal'; import PhotoViewerModal from './src/components/PhotoViewerModal'; -import { Recipe, ColorAdjustments, CustomWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types'; +import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types'; import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; @@ -86,6 +86,14 @@ export default function App() { // WATERMARK tab ROTATE chip: clockwise degrees, applied to the GPS lines and // the custom text in both the preview and the exported file. const [wmRotation, setWmRotation] = useState(0); + // GPS mark placement (WATERMARK tab drag + pinch): fractions of the frame / + // photo area plus a size multiplier, same space as the custom mark. There is + // nothing to accept — the panel being open IS the edit session. + const DEFAULT_GPS_WM: GpsWatermark = { x: 0.05, y: 0.1, size: 1 }; + const [gpsWm, setGpsWm] = useState({ ...DEFAULT_GPS_WM }); + // FRAME tab ORIENTATION chip (WALL FRAME only): hang the artwork landscape + // (the PNG's own 4000x3117) instead of the default portrait rotation. + const [wallLandscape, setWallLandscape] = useState(false); // The mark the EXPORT burns in. The chip above is the live draft (typing + // dragging update it); only ACCEPT in the WATERMARK tab copies the draft in // here — until then the file keeps the previous stamp (or none). @@ -442,6 +450,7 @@ export default function App() { } setCustomWm({ ...DEFAULT_CUSTOM_WM }); setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); + setGpsWm({ ...DEFAULT_GPS_WM }); }; const handleUpdateAdjustments = (updates: Partial) => { @@ -778,6 +787,8 @@ export default function App() { aspect: aspectRatio === 'full' ? undefined : aspectRatio, watermark: wmForExport(), watermarkRotation: wmRotation, + gpsWatermark: gpsWm, + wallframeLandscape: wallLandscape, }; return NATIVE_EXPORT ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) @@ -846,6 +857,8 @@ export default function App() { aspect: libraryAspect, watermark: wmForExport(), watermarkRotation: wmRotation, + gpsWatermark: gpsWm, + wallframeLandscape: wallLandscape, } ) : await processAndExportPhoto( @@ -865,6 +878,8 @@ export default function App() { aspect: libraryAspect, watermark: wmForExport(), watermarkRotation: wmRotation, + gpsWatermark: gpsWm, + wallframeLandscape: wallLandscape, // Pinch/drag inside a polaroid card window or wall-frame opening: // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, @@ -1059,6 +1074,10 @@ export default function App() { imageAdjustTarget={imageAdjustTarget} customWm={customWm} wmRotation={wmRotation} + gpsWm={gpsWm} + onGpsWmChange={(x, y, size) => setGpsWm({ x, y, size })} + onWmSizeChange={(size) => setCustomWm((p) => ({ ...p, size }))} + wallLandscape={wallLandscape} // The mark only captures image touches while its panel is open, so a // watermark left ON cannot block tap-to-focus or pinch/zoom. wmEditing={openTab === 'watermark'} @@ -1099,6 +1118,8 @@ export default function App() { selectedFrame={selectedFrame} photoRotation={photoRotation} onToggleRotation={() => setPhotoRotation((r) => (r === 0 ? 90 : 0))} + wallLandscape={wallLandscape} + onToggleWallLandscape={() => setWallLandscape((v) => !v)} useGeotag={useGeotag} openParam={openParamKey} onOpenParamChange={setOpenParamKey} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 6a370fe..0c4a9c3 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -48,6 +48,10 @@ interface AdjustmentPanelProps { // so it fits the chosen frame better. photoRotation: 0 | 90; onToggleRotation: () => void; + // FRAME tab (WALL FRAME only): hang the artwork landscape instead of the + // default portrait rotation. + wallLandscape: boolean; + onToggleWallLandscape: () => void; onToggleGeotag: (enabled: boolean) => void; onSaveRecipe: (name: string) => void; onDeleteRecipe: (id: string) => void; @@ -142,6 +146,8 @@ export default function AdjustmentPanel({ onUpdateAdjustments, onUpdateFrame, photoRotation, + wallLandscape, + onToggleWallLandscape, onToggleGeotag, onDeleteRecipe, customWm, @@ -508,6 +514,13 @@ export default function AdjustmentPanel({ active: photoRotation !== 0, onPress: onToggleRotation, })} + {selectedFrame === 'wallframe' && + renderChip({ + key: 'wall-orient', + label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT', + active: wallLandscape, + onPress: onToggleWallLandscape, + })} ); diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 9acb5b2..47ea4aa 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -105,6 +105,13 @@ interface ViewfinderProps { y: number; }; onWmPositionChange?: (x: number, y: number) => void; + // GPS mark placement (WATERMARK tab drag + pinch): the same 0..1 fractions + // of the same area customWm uses, TOP-LEFT anchored; size multiplies the + // base 3.2%-of-width size. Absent = the historical fixed top-left look. + gpsWm?: { x: number; y: number; size: number }; + onGpsWmChange?: (x: number, y: number, size: number) => void; + // Pinch on the custom mark: its SIZE, exactly like the panel's SIZE chip. + onWmSizeChange?: (size: number) => void; // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270). Rotates the // custom text AND the GPS lines around their own anchors — same angle the // export burns in. @@ -115,6 +122,8 @@ interface ViewfinderProps { // to focus / zoom) while this is set, so a watermark left ON can never // swallow the camera's tap-to-focus or the library's pinch/double-tap. wmEditing?: boolean; + // WALL FRAME hung landscape (4000x3117) instead of portrait. + wallLandscape?: boolean; } export interface ViewfinderHandle { @@ -197,10 +206,14 @@ const Viewfinder = forwardRef(function Viewfi imageAdjustTarget, customWm = { enabled: false, text: '', x: 0.5, y: 0.5 }, onWmPositionChange, + gpsWm, + onGpsWmChange, + onWmSizeChange, onWmEdit, wmEditing = false, photoRotation = 0, wmRotation = 0, + wallLandscape = false, }: ViewfinderProps, ref ) { @@ -305,6 +318,17 @@ const Viewfinder = forwardRef(function Viewfi require('../../assets/NotoEmoji-GPS.ttf'), Math.round(vw * 0.032) ); + // GPS mark SIZE (pinch while placing it): the same two faces, re-sized, so + // the preview grows with the multiplier the export burns in. + const gpsIconSize = Math.round(vw * 0.032 * (gpsWm?.size ?? 1)); + const gpsFont = useMemo( + () => (customFont ? Skia.Font(customFont.getTypeface(), gpsIconSize) : null), + [customFont, gpsIconSize] + ); + const gpsIconFont = useMemo( + () => (iconFont ? Skia.Font(iconFont.getTypeface(), gpsIconSize) : null), + [iconFont, gpsIconSize] + ); const adjustments = meteringAdjustments ? { ...recipe.adjustments, ...meteringAdjustments } @@ -629,12 +653,14 @@ const Viewfinder = forwardRef(function Viewfi kind = 'param'; v0 = t.value; param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange }; - } else if (wmArmed) { - // Watermark armed: every touch drags/places the text (an open param - // slider keeps priority; the AE-lock EV drag yields while armed). + } else if (wmArmed || gpsWmArmed) { + // Watermark armed: every touch drags/places the mark under it (an open + // param slider keeps priority; the AE-lock EV drag yields while armed). kind = 'wm'; const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; - placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); + const tgt = wmTargetAt(t?.locationX ?? 0, t?.locationY ?? 0) ?? (wmArmed ? 'custom' : 'gps'); + wmDragTgtRef.current = tgt; + placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0, tgt); } else if (focusPoint && onExposureChange) { kind = 'ev'; v0 = evStops; @@ -646,8 +672,30 @@ const Viewfinder = forwardRef(function Viewfi if (!g || g.kind === 'focus') return; if (g.kind === 'wm') { // Watermark drag: follow the finger; the commit happens on release below. - const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; - placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); + const ts = e?.nativeEvent?.touches ?? []; + if (ts.length >= 2 && wmEditing) { + // A second finger turns placement into a pinch: it scales the mark + // (the camera frame behind it never changes). + const d = Math.hypot( + ts[0].locationX - ts[1].locationX, + ts[0].locationY - ts[1].locationY + ); + const p = wmPinchRef.current; + if (!p) { + const tgt = wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps'); + wmPinchRef.current = { tgt, d, size: wmSizeOf(tgt) }; + } else { + applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); + g.moved = true; + } + return; + } + const t = ts[0] ?? e?.nativeEvent; + placeWm( + t?.pageX ?? t?.locationX ?? 0, + t?.pageY ?? t?.locationY ?? 0, + wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps') + ); g.moved = true; return; } @@ -678,6 +726,8 @@ const Viewfinder = forwardRef(function Viewfi const onImageDragEnd = (e: any) => { const g = imgDragRef.current; imgDragRef.current = null; + wmPinchRef.current = null; + wmDragTgtRef.current = null; if (!g) return; if (g.kind === 'wm') { // A tap places the text at the tapped point; a drag already moved it. @@ -993,8 +1043,8 @@ const Viewfinder = forwardRef(function Viewfi // is cover-cropped by the mat opening (never letterboxed inside the window). const wallGeo = useMemo(() => { if (selectedFrame !== 'wallframe') return null; - return wallframeLayout(vw, vh, 'contain'); - }, [selectedFrame, vw, vh]); + return wallframeLayout(vw, vh, 'contain', wallLandscape); + }, [selectedFrame, vw, vh, wallLandscape]); // Framed photo window (library only) whose content the user may pinch/drag: // polaroid card window or wall-frame opening. Null for plain frames (their // whole composition region is the photo) and for the live camera. @@ -1105,6 +1155,11 @@ const Viewfinder = forwardRef(function Viewfi const wmVisible = !!(customWm.enabled && customWm.text.trim().length > 0); const wmArmed = !!(wmVisible && wmEditing && onWmPositionChange); const [wmDragPos, setWmDragPos] = useState<{ x: number; y: number } | null>(null); + // GPS lines: same deal, but they have no text editor — armed whenever the + // panel is open and the mark is drawn. + const gpsWmVisible = !!(useGeotag && gpsInfo && customFont && iconFont); + const gpsWmArmed = !!(gpsWmVisible && wmEditing && onGpsWmChange); + const [gpsDragPos, setGpsDragPos] = useState<{ x: number; y: number } | null>(null); // Photo/window area the 0..1 fractions map onto — identical rule to // renderGPSWatermark so both marks share the user-visible coordinate space. const wmArea = () => { @@ -1130,6 +1185,19 @@ const Viewfinder = forwardRef(function Viewfi ay: area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35), }; }; + // The GPS block's TOP-LEFT corner (the two lines grow right and down from + // there) — the same 0..1 fractions the export burns in, so preview and file + // agree. Defaults are the old fixed top-left look. + const gpsAnchor = () => { + const area = wmArea(); + const fracX = gpsDragPos?.x ?? gpsWm?.x ?? 0.05; + const fracY = gpsDragPos?.y ?? gpsWm?.y ?? 0.1; + return { + ax: area.dx + fracX * area.dw, + ay: area.dy + fracY * area.dh, + iconSize: gpsIconSize, + }; + }; const wmRad = (wmRotation * Math.PI) / 180; const wmFontSize = Math.round(vw * 0.032 * (customWm.size ?? 1)); const wmColor = customWm.color ?? '#f59e0b'; @@ -1155,18 +1223,41 @@ const Viewfinder = forwardRef(function Viewfi // reading the wmDragPos state would commit the PREVIOUS render's value (a // tap with no move events committed nothing at all). Mirror it in a ref. const wmDragPosRef = useRef<{ x: number; y: number } | null>(null); - const placeWm = (x: number, y: number) => { - if (!wmArmed) return; + const gpsDragPosRef = useRef<{ x: number; y: number } | null>(null); + // The mark a running gesture grabbed, decided at grant time (the box under + // the first touch) so a drag keeps hold of the mark it started on. + const wmDragTgtRef = useRef<'custom' | 'gps' | null>(null); + const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') => { const pos = wmFracFromXY(x, y); - wmDragPosRef.current = pos; - setWmDragPos(pos); + if (tgt === 'custom') { + if (!wmArmed) return; + wmDragPosRef.current = pos; + setWmDragPos(pos); + return; + } + if (!gpsWmArmed) return; + gpsDragPosRef.current = pos; + setGpsDragPos(pos); }; const commitWm = () => { const pos = wmDragPosRef.current; wmDragPosRef.current = null; setWmDragPos(null); - if (!wmArmed || !onWmPositionChange || !pos) return; - onWmPositionChange(pos.x, pos.y); + if (wmArmed && onWmPositionChange && pos) onWmPositionChange(pos.x, pos.y); + const gpos = gpsDragPosRef.current; + gpsDragPosRef.current = null; + setGpsDragPos(null); + if (gpsWmArmed && onGpsWmChange && gpos) onGpsWmChange(gpos.x, gpos.y, gpsWm?.size ?? 1); + }; + // Two fingers on a mark: scale THAT mark (custom SIZE / GPS mark size) + // instead of zooming the photo. 0.4..4x keeps it on the card. + const wmPinchRef = useRef<{ tgt: 'custom' | 'gps'; d: number; size: number } | null>(null); + const wmSizeOf = (tgt: 'custom' | 'gps') => + tgt === 'custom' ? customWm.size ?? 1 : gpsWm?.size ?? 1; + const applyWmScale = (tgt: 'custom' | 'gps', size: number) => { + const s = Math.max(0.4, Math.min(4, size)); + if (tgt === 'custom') onWmSizeChange?.(s); + else onGpsWmChange?.(gpsWm?.x ?? 0.05, gpsWm?.y ?? 0.1, s); }; // Bounding box of the drawn mark (same anchor maths as renderCustomWatermark) // so a double-tap ON the text re-opens the editor instead of moving it. @@ -1176,27 +1267,49 @@ const Viewfinder = forwardRef(function Viewfi const w = Math.max(wmFont.measureText(customWm.text.trim()).width, wmFontSize * 2); return { x: ax, y: ay - wmFontSize, w, h: Math.round(wmFontSize * 1.4) }; }; - const wmBoxHit = ( + // A rotated mark needs the tap un-spun around the same anchor, otherwise + // the box stops covering the visible glyphs. + const boxHitAt = ( box: { x: number; y: number; w: number; h: number } | null, x: number, - y: number + y: number, + origin: { x: number; y: number } ) => { if (!box) return false; - // A rotated mark needs the tap un-spun around the same anchor, otherwise - // the box stops covering the visible glyphs. if (wmRotation) { - const { ax, ay } = wmAnchor(); const c = Math.cos(-wmRad); const s = Math.sin(-wmRad); - const dx = x - ax; - const dy = y - ay; - x = ax + dx * c - dy * s; - y = ay + dx * s + dy * c; + const dx = x - origin.x; + const dy = y - origin.y; + x = origin.x + dx * c - dy * s; + y = origin.y + dx * s + dy * c; } return ( x >= box.x - 8 && x <= box.x + box.w + 8 && y >= box.y - 8 && y <= box.y + box.h + 8 ); }; + const wmBoxHit = ( + box: { x: number; y: number; w: number; h: number } | null, + x: number, + y: number + ) => boxHitAt(box, x, y, wmAnchor()); + // The GPS block covers both lines out to the widest one (the timestamp). + const gpsBox = () => { + const { ax, ay, iconSize } = gpsAnchor(); + return { x: ax, y: ay, w: Math.round(iconSize * 8.5), h: Math.round(iconSize * 2.6) }; + }; + const gpsBoxHit = (x: number, y: number) => boxHitAt(gpsBox(), x, y, gpsAnchor()); + // Which mark a touch is on. A box hit always wins; with neither box hit the + // lone armed mark takes the touch, so a tap anywhere still places it. + const wmTargetAt = (x: number, y: number): 'custom' | 'gps' | null => { + const custom = wmArmed && wmBoxHit(wmBox(), x, y); + const gps = gpsWmArmed && gpsBoxHit(x, y); + if (custom) return 'custom'; + if (gps) return 'gps'; + if (wmArmed) return 'custom'; + if (gpsWmArmed) return 'gps'; + return null; + }; // Library framed (polaroid/wall) returns reuse the SAME responder handlers as // plain library (see the framed JSX below): single-finger drags place/move // the text watermark while armed, and otherwise DRAG the photo inside the @@ -1209,27 +1322,50 @@ const Viewfinder = forwardRef(function Viewfi panRef.current = ts.length === 1 ? { x: ts[0].locationX, y: ts[0].locationY, tx: libZoom.tx, ty: libZoom.ty } : null; panUsedRef.current = false; + if (ts.length === 1 && (wmArmed || gpsWmArmed) && libZoom.s <= 1.01) { + wmDragTgtRef.current = wmTargetAt(ts[0].locationX, ts[0].locationY); + } if (ts.length >= 2) { const [a, b] = ts; + const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); + const mx = (a.locationX + b.locationX) / 2; + const my = (a.locationY + b.locationY) / 2; pinchUsedRef.current = true; lastTapRef.current = null; - pinchRef.current = { - d: Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY), - s: libZoom.s, - tx: libZoom.tx, - ty: libZoom.ty, - fx: (a.locationX + b.locationX) / 2, - fy: (a.locationY + b.locationY) / 2, - }; + if (wmEditing) { + // WATERMARK tab open: a two-finger gesture belongs to the marks. It + // scales the one under the fingers and NEVER zooms the photo — pinch + // zoom comes back the moment the panel closes. + const tgt = wmTargetAt(mx, my); + wmPinchRef.current = tgt ? { tgt, d, size: wmSizeOf(tgt) } : null; + pinchRef.current = null; + return; + } + pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: mx, fy: my }; } }; const onLibTouchMove = (e: any) => { const ts = e.nativeEvent.touches; - // Watermark armed + one finger + unzoomed: the drag moves the text (not the + // WATERMARK tab open: two fingers scale the mark under them (or do + // nothing) — the photo itself only zooms once the editor is closed. + if (ts.length >= 2 && wmEditing) { + const p = wmPinchRef.current; + if (p) { + const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY); + applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1)); + } + pinchUsedRef.current = true; + return; + } + // Watermark armed + one finger + unzoomed: the drag moves the mark (not the // photo). Zoomed-in keeps pinch/pan so the user can inspect detail first. - if (wmArmed && ts.length === 1 && libZoom.s <= 1.01) { + if ((wmArmed || gpsWmArmed) && ts.length === 1 && libZoom.s <= 1.01) { const t = ts[0]; - placeWm(t.pageX ?? t.locationX, t.pageY ?? t.locationY); + placeWm( + t.pageX ?? t.locationX, + t.pageY ?? t.locationY, + wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps') + ); return; } // One finger, no pinch running: drag the photo inside the framed window @@ -1281,6 +1417,8 @@ const Viewfinder = forwardRef(function Viewfi const wasPinch = pinchUsedRef.current; const wasPan = panUsedRef.current; pinchRef.current = null; + wmPinchRef.current = null; + wmDragTgtRef.current = null; pinchUsedRef.current = false; panRef.current = null; panUsedRef.current = false; @@ -1293,13 +1431,13 @@ const Viewfinder = forwardRef(function Viewfi // editor (and the placement layer with it). A single tap is NOT consumed — // this block records nothing, so the viewer rules below still run and the // photo keeps pinch / double-tap zoom / pan. - if (!wmArmed && wmVisible && libZoom.s <= 1.01) { + if (!wmArmed && !gpsWmArmed && (wmVisible || gpsWmVisible) && libZoom.s <= 1.01) { const prev = lastTapRef.current; if ( prev && Date.now() - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50 && - wmBoxHit(wmBox(), x, y) + (wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y)) ) { lastTapRef.current = null; onWmEdit?.(); @@ -1308,23 +1446,23 @@ const Viewfinder = forwardRef(function Viewfi } // Watermark armed: a tap places + commits the text and skips the double-tap // zoom bookkeeping (double-tap zoom is disabled while placing). - if (wmArmed && libZoom.s <= 1.01) { + if ((wmArmed || gpsWmArmed) && libZoom.s <= 1.01) { const now = Date.now(); const prev = lastTapRef.current; lastTapRef.current = { x, y, t: now }; - // Second tap ON the mark (keyboard hidden, still placing): back to the - // text editor — the mark is only embedded by ACCEPT in the panel. + // Second tap ON a mark (keyboard hidden, still placing): back to the + // editor — the mark is only embedded by ACCEPT in the panel. if ( prev && now - prev.t < 350 && Math.hypot(x - prev.x, y - prev.y) < 50 && - wmBoxHit(wmBox(), x, y) + (wmBoxHit(wmBox(), x, y) || gpsBoxHit(x, y)) ) { lastTapRef.current = null; onWmEdit?.(); return; } - placeWm(x, y); + placeWm(x, y, wmTargetAt(x, y) ?? 'custom'); commitWm(); return; } @@ -1522,46 +1660,32 @@ const Viewfinder = forwardRef(function Viewfi }; const renderGPSWatermark = () => { - if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null; + if (!gpsWmVisible || !gpsFont || !gpsIconFont) return null; const locationName = gpsInfo.locality || 'STREET VIEW'; const d = new Date(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())}`; - // Anchor to the PHOTO's top-left, not the screen. Bottom anchoring put the - // mark under the floating chip rows (open WB/FRAME panel covered it), so it - // now lives at the top of the photo/window: the bottom sheet can never reach - // it. In camera mode the photo fills the screen; in library mode it is - // contain-fitted with letterbox — use imageFitRect so the mark sits on the - // photo corner. With a polaroid frame the photo lives inside the card - // window — anchor to that. Always clear the floating TopBar (recipe name + - // SAVE), which ends around insets.top + 56 dp. - const win = polaroidGeo?.window ?? wallGeo?.window ?? null; - const area = wmArea(); - const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size - const topBarBottom = insets.top + 56; - const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom); - const xOffset = - area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04)); - // Two stacked lines (pin/location above camera/timestamp). - const row1Y = topBase + Math.round(iconSize * 1.1); - const row2Y = topBase + Math.round(iconSize * 2.3); - // One clear space (0.6em ≈ a mono space) between each icon and its text. + // TOP-LEFT anchored two-line block at the dragged fraction of the same area + // the custom mark uses (frame card / photo window / screen). A framed + // export's canvas IS that frame, so the fractions carry straight to the + // burn-in — exportEngine step 8 draws with the identical formula. + const { ax, ay, iconSize } = gpsAnchor(); + const row1Y = ay + Math.round(iconSize * 1.1); + const row2Y = ay + Math.round(iconSize * 2.3); + // One clear space (0.6em ~ a mono space) between each icon and its text. const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6); - const pinW = iconFont.measureText('📍'); - const camW = iconFont.measureText('📷'); + const pinW = gpsIconFont.measureText('📍'); + const camW = gpsIconFont.measureText('📷'); return ( - + {/* Icons white so they read as glyphs, not part of the amber text */} - - - - + + + + ); }; diff --git a/src/types/index.ts b/src/types/index.ts index 4406e9f..a8efa99 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,3 +97,13 @@ export interface CustomWatermark { size?: number; // multiplier on the base 3.2%-of-width text size font?: string | null; // OS font family; null/absent = bundled font } + +// GPS watermark placement (WATERMARK tab drag + pinch): x/y are 0..1 fractions +// of the SAME area the custom mark uses (frame card / photo rect / screen), and +// the anchor is the top-left corner of the two-line block. size multiplies the +// base 3.2%-of-width size. Defaults reproduce the old fixed top-left look. +export interface GpsWatermark { + x: number; + y: number; + size: number; +} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 49c908f..641484a 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -7,7 +7,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../type import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; -import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils'; +import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; import { rotateSkImage90 } from './skiaImage'; @@ -50,6 +50,12 @@ export interface ExportOptions { // BOTH the GPS lines and the custom text, each around its own anchor (the // same anchor Viewfinder rotates around, so the preview matches the file). watermarkRotation?: number; + // WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same + // area the custom mark uses (a framed export's canvas IS the frame) plus a + // multiplier on the base 3.2%-of-width size. Absent = the old top-left look. + gpsWatermark?: { x: number; y: number; size: number } | null; + // WALL FRAME hung the other way round (4000x3117, artwork un-rotated). + wallframeLandscape?: boolean; // Library framed-window photo reposition (Viewfinder.getFrameWindowZoom): // after pinch/drag inside a polaroid card window or a wall-frame opening, // s = scale and u/v = the fraction of the photo's cover plane sitting at the @@ -403,8 +409,9 @@ export async function processAndExportPhoto( // processed picture is composited, uncropped, into the window of a bigger // off-white card (real print proportions, see frameUtils) — the export IS // the card, with the photo scaled to the window so it keeps ~WIN_W of the - // card width. WALL FRAME: the export IS the ROTATED frame PNG at its native - // portrait size (WALLFRAME_W x WALLFRAME_H), with the photo cover-cropped + // card width. WALL FRAME: the export IS the frame PNG at its native size — + // portrait (rotated 90° CW) or landscape, per the FRAME tab's ORIENTATION + // chip — with the photo cover-cropped // (center) into the fixed 2:3 window (the mat opening of a real frame). // Other frames (classic white border, legacy cinematic bars) draw over the // photo-sized canvas as before. @@ -412,7 +419,6 @@ export async function processAndExportPhoto( let stampCanvas = canvas; let stampW = width; let stampH = height; - let framedWindow: { x: number; y: number; w: number; h: number } | null = null; if (frameId === 'polaroid') { const photoImg = surface.makeImageSnapshot(); const outW = width; // card width matches the photo width @@ -474,15 +480,15 @@ export async function processAndExportPhoto( stampCanvas = cardCanvas; stampW = outW; stampH = outH; - framedWindow = win; } else if (frameId === 'wallframe') { // Export at the ROTATED frame's native size (the frame is the product, so // the file is exactly frame-sized — never the photo's own dimensions). const photoImg = surface.makeImageSnapshot(); const pw = photoImg.width(); const ph = photoImg.height(); - const outW = WALLFRAME_W; // 3117 - const outH = WALLFRAME_H; // 4000 + const wallLand = options?.wallframeLandscape === true; + const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W; // 3117 / 4000 + const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H; // 4000 / 3117 const cardSurface = createSurface(outW, outH); if (!cardSurface) { console.error('Failed to create wallframe surface'); @@ -512,7 +518,7 @@ export async function processAndExportPhoto( console.error('Failed to decode wallframe.png'); return null; } - const wl = wallframeLayout(outW, outH, 'fill'); + const wl = wallframeLayout(outW, outH, 'fill', wallLand); const win = wl.window; // Cover-crop (center): scale the processed photo so it fills the fixed // 3:2 window and cut the overflowing edges equally on both sides. @@ -544,15 +550,19 @@ export async function processAndExportPhoto( // mat covers the rest. The source PNG is landscape — rotate it 90° CW // onto the portrait canvas (same affine as wallframeLayout.drawMatrix). cardCanvas.save(); - cardCanvas.translate(outW, 0); - cardCanvas.rotate(90, 0, 0); - cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + if (wallLand) { + // Artwork is already landscape: outW/outH ARE its own size. + cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + } else { + cardCanvas.translate(outW, 0); + cardCanvas.rotate(90, 0, 0); + cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + } cardCanvas.restore(); outputSurface = cardSurface; stampCanvas = cardCanvas; stampW = outW; stampH = outH; - framedWindow = win; } else { drawFrameOnCanvas(canvas, width, height, frameId); } @@ -574,41 +584,40 @@ export async function processAndExportPhoto( if (typeface && iconTypeface) { const fontSize = Math.round(stampW * 0.032); // 3.2% of output width - const font = Skia.Font(typeface, fontSize); - const iconFont = Skia.Font(iconTypeface, fontSize); const textPaint = Skia.Paint(); textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500 const iconPaint = Skia.Paint(); iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text - // GPS lines keep the historical bottom-left anchor (framed -> inside - // the photo window; other frames -> bottom-left of the image). + // GPS block: its TOP-LEFT corner sits at the dragged fraction of the + // same area the viewfinder maps onto (a framed export's canvas IS the + // card/frame), so the preview and the file agree. Defaults = the old + // fixed top-left look. if (useGeotag && gpsInfo) { const locationName = gpsInfo.locality || 'STREET VIEW'; const d = new Date(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())}`; - const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em) - - let baseX: number; - let baseY: number; - if (framedWindow) { - baseX = framedWindow.x + framedWindow.w * 0.05; - baseY = framedWindow.y + framedWindow.h - Math.round(fontSize * 1.4); - } else { - baseX = fontSize * 1.2; - baseY = stampH - fontSize * 1.5; - } + const gpsPos = options?.gpsWatermark ?? null; + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1)); + const gpsFont = Skia.Font(typeface, gpsSize); + const gpsIconFont = Skia.Font(iconTypeface, gpsSize); + const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em) + const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW; + const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH; + const row1Y = baseY + Math.round(gpsSize * 1.1); + const row2Y = baseY + Math.round(gpsSize * 2.3); // Spin the whole two-line block around its top-left (the anchor the // viewfinder uses). 0 = the historical upright look. const wmRot = options?.watermarkRotation ?? 0; stampCanvas.save(); - if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY - Math.round(fontSize * 2.4)); - stampCanvas.drawText('📍', baseX, baseY - fontSize * 1.4, iconPaint, iconFont); - stampCanvas.drawText(locationName, baseX + gap, baseY - fontSize * 1.4, textPaint, font); - stampCanvas.drawText('📷', baseX, baseY, iconPaint, iconFont); - stampCanvas.drawText(timestampStr, baseX + gap, baseY, textPaint, font); + if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY); + stampCanvas.drawText('📍', baseX, row1Y, iconPaint, gpsIconFont); + stampCanvas.drawText(locationName, baseX + gpsGap, row1Y, textPaint, gpsFont); + stampCanvas.drawText('📷', baseX, row2Y, iconPaint, gpsIconFont); + stampCanvas.drawText(timestampStr, baseX + gpsGap, row2Y, textPaint, gpsFont); stampCanvas.restore(); } diff --git a/src/utils/frameUtils.ts b/src/utils/frameUtils.ts index 672c064..80ec12b 100644 --- a/src/utils/frameUtils.ts +++ b/src/utils/frameUtils.ts @@ -53,6 +53,17 @@ export const WALLFRAME_WIN = { w: 1766 / 3117, // ≈ 0.56657 h: 2647 / 4000, // 0.66175 } as const; +// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the +// canvas is the artwork (4000x3117) and the opening is the source window as +// fractions of it. Same 3:2 mat opening, hung the other way round. +export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000 +export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117 +export const WALLFRAME_LAND_WIN = { + left: 675 / WALLFRAME_PNG_W, // 0.16875 + top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688 + w: 2647 / WALLFRAME_PNG_W, // 0.66175 + h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657 +} as const; export interface WallframeLayout { frame: { x: number; y: number; w: number; h: number }; @@ -63,36 +74,45 @@ export interface WallframeLayout { drawMatrix: number[]; } -// Geometry of the ROTATED wall-frame inside an arbitrary container. The frame -// is contain-fitted ('contain', centered, letterboxed) for the live viewfinder, +// Geometry of the wall-frame inside an arbitrary container. The frame is +// contain-fitted ('contain', centered, letterboxed) for the live viewfinder, // or fills the container exactly ('fill', export canvas already at frame size). +// `landscape` hangs the artwork the other way round: same PNG, no rotation. export function wallframeLayout( contW: number, contH: number, - mode: 'contain' | 'fill' = 'contain' + mode: 'contain' | 'fill' = 'contain', + landscape = false ): WallframeLayout { + const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W; + const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H; + const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN; let frameW: number; let frameH: number; if (mode === 'fill') { frameW = contW; frameH = contH; } else { - const s = Math.min(contW / WALLFRAME_W, contH / WALLFRAME_H); - frameW = WALLFRAME_W * s; - frameH = WALLFRAME_H * s; + const s = Math.min(contW / FW, contH / FH); + frameW = FW * s; + frameH = FH * s; } const fx = (contW - frameW) / 2; const fy = (contH - frameH) / 2; - const scale = frameW / WALLFRAME_W; // == frameH / WALLFRAME_H + const scale = frameW / FW; // == frameH / FH return { frame: { x: fx, y: fy, w: frameW, h: frameH }, window: { - x: fx + WALLFRAME_WIN.left * frameW, - y: fy + WALLFRAME_WIN.top * frameH, - w: WALLFRAME_WIN.w * frameW, - h: WALLFRAME_WIN.h * frameH, + x: fx + WIN.left * frameW, + y: fy + WIN.top * frameH, + w: WIN.w * frameW, + h: WIN.h * frameH, }, - drawMatrix: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1], + // Portrait: the source PNG must be turned 90° CW onto the canvas. + // Landscape: it already sits the right way up — scale and centre it. + drawMatrix: landscape + ? [scale, 0, fx, 0, scale, fy, 0, 0, 1] + : [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1], }; }