From 144b211d35bc114ce06d261a553f11cf10502ad7 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 10 Sep 2026 07:17:14 +0700 Subject: [PATCH] Add WATERMARK tab with custom draggable text; hide FRAME in camera; fix double-dark after highlight capture --- App.tsx | 54 +++++++++- src/components/AdjustmentPanel.tsx | 46 ++++++++- src/components/ToolRail.tsx | 14 ++- src/components/Viewfinder.tsx | 161 +++++++++++++++++++++++++++-- src/types/index.ts | 12 +++ src/utils/exportEngine.ts | 65 ++++++++---- src/utils/nativeExport.ts | 5 + 7 files changed, 315 insertions(+), 42 deletions(-) diff --git a/App.tsx b/App.tsx index 0fbec5d..55e5715 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, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode } from './src/types'; +import { Recipe, ColorAdjustments, CustomWatermark, 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'; @@ -55,6 +55,11 @@ export default function App() { const [selectedFrame, setSelectedFrame] = useState('none'); const [useGeotag, setUseGeotag] = useState(true); const [gpsInfo, setGpsInfo] = useState(null); + // Custom text watermark (WATERMARK tab -> CUSTOM WATERMARK chip): enabled + + // text + drag position as 0..1 fractions of the photo area. Lives here so the + // live Viewfinder overlay, the panel input and every export share one state. + const DEFAULT_CUSTOM_WM: CustomWatermark = { enabled: false, text: '', x: 0.5, y: 0.5 }; + const [customWm, setCustomWm] = useState({ ...DEFAULT_CUSTOM_WM }); // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. const [aspectRatio, setAspectRatio] = useState('full'); const [rawEnabled, setRawEnabled] = useState(false); @@ -187,6 +192,7 @@ export default function App() { shutterSound, selectedRecipeId: selectedRecipe?.id || null, gpsInfo, + customWm, }; try { await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session)); @@ -196,7 +202,7 @@ export default function App() { } }); return () => subscription.remove(); - }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio, meterMode, shutterSound]); + }, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, aspectRatio, meterMode, shutterSound]); const initSession = async () => { const list = await getAllRecipes(); @@ -264,6 +270,19 @@ export default function App() { if (session.meterMode === 'evaluative' || session.meterMode === 'highlight') setMeterMode(session.meterMode); if (session.shutterSound === 'on' || session.shutterSound === 'off') setShutterSound(session.shutterSound); if (session.gpsInfo) setGpsInfo(session.gpsInfo); + if (session.customWm && typeof session.customWm === 'object') { + // Restore defensively: a session saved by an older build may miss the + // field entirely or hold a malformed shape. + const raw = session.customWm as Partial; + const clamp01 = (v: unknown, fb: number) => + typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : fb; + setCustomWm({ + enabled: raw.enabled === true, + text: typeof raw.text === 'string' ? raw.text : '', + x: clamp01(raw.x, 0.5), + y: clamp01(raw.y, 0.5), + }); + } if (session.selectedRecipeId) { // Film sims are never persisted in the recipe list (sims applied on the // fly) — resolve sim ids via FILM_SIMS, real recipes via the list. @@ -514,6 +533,14 @@ export default function App() { // Thumbnail/preview target for the camera-mode right button: newest photo. const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null; + // Watermark handed to the export engines (Skia + native): null while disabled + // or empty, else trimmed text + position. Trimming here keeps the stamped + // text identical to what the preview drew. + const wmForExport = (): { text: string; x: number; y: number } | null => + customWm.enabled && customWm.text.trim().length > 0 + ? { text: customWm.text.trim(), x: customWm.x, y: customWm.y } + : null; + const handleCapture = async () => { if (isProcessing) return; @@ -593,6 +620,7 @@ export default function App() { sharpen: true, evFromCamera: true, aspect: aspectRatio === 'full' ? undefined : aspectRatio, + watermark: wmForExport(), }; return NATIVE_EXPORT ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) @@ -650,7 +678,10 @@ export default function App() { useGeotag, gpsInfo, // Library export follows the same composition ratio as the viewfinder band. - { aspect: aspectRatio === 'full' ? undefined : aspectRatio } + { + aspect: aspectRatio === 'full' ? undefined : aspectRatio, + watermark: wmForExport(), + } ) : await processAndExportPhoto( libraryImageUri, @@ -664,7 +695,10 @@ export default function App() { useGeotag, gpsInfo, // Library export follows the same composition ratio as the viewfinder band. - { aspect: aspectRatio === 'full' ? undefined : aspectRatio } + { + aspect: aspectRatio === 'full' ? undefined : aspectRatio, + watermark: wmForExport(), + } ); setIsProcessing(false); @@ -691,6 +725,12 @@ export default function App() { if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') { setGpsInfo(null); } + // FRAME only exists in library mode — close the panel when switching back + // to camera so a dead frame chip row never lingers over the live preview. + if (next === 'camera' && openTab === 'frame') { + setOpenTab(null); + setOpenParamKey(null); + } setMode(next); }; @@ -838,6 +878,8 @@ export default function App() { meteringEvOffset={meteringActive ? METER_HL_EV : 0} meteringAdjustments={meteringAdj} imageAdjustTarget={imageAdjustTarget} + customWm={customWm} + onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))} /> @@ -872,11 +914,13 @@ export default function App() { onToggleGeotag={handleToggleGeotag} onSaveRecipe={handleSaveRecipe} onDeleteRecipe={handleDeleteRecipe} + customWm={customWm} + onUpdateCustomWm={setCustomWm} /> )} - + void; onSaveRecipe: (name: string) => void; onDeleteRecipe: (id: string) => void; + // WATERMARK tab state — lifted to App so the live Viewfinder overlay, this + // input and the export options all read the same object. + customWm: CustomWatermark; + onUpdateCustomWm: (wm: CustomWatermark) => void; } interface ChipDef { @@ -102,6 +106,8 @@ export default function AdjustmentPanel({ onUpdateFrame, onToggleGeotag, onDeleteRecipe, + customWm, + onUpdateCustomWm, }: AdjustmentPanelProps) { // The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is // open above the chips (same one-row-at-a-time pattern as openParam — which @@ -230,6 +236,7 @@ export default function AdjustmentPanel({ wb: toRows(PARAM_DEFS.wb), filters: toRows(PARAM_DEFS.filters), frame: [], + watermark: [], }; // Active WB chip: the remembered preset wins while the engine pair still @@ -409,12 +416,28 @@ export default function AdjustmentPanel({ selectedFrame, (val) => onUpdateFrame(val as FrameId) )} + + ); + + case 'watermark': + return ( + {renderChip({ key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, onPress: () => onToggleGeotag(!useGeotag), })} + {renderChip({ + key: 'custom-wm', + label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', + active: customWm.enabled, + onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }), + })} ); @@ -516,6 +539,23 @@ export default function AdjustmentPanel({ )} + {activeTab === 'watermark' && customWm.enabled && ( + + onUpdateCustomWm({ ...customWm, text })} + placeholder="TYPE WATERMARK TEXT" + placeholderTextColor="#a1a1aa" + className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300" + maxLength={80} + /> + + {customWm.text.trim().length > 0 + ? 'DRAG ON IMAGE TO MOVE — TAP TO PLACE' + : 'TYPE TEXT ABOVE, THEN DRAG ON IMAGE TO PLACE'} + + + )} {chipsRow} void; + // FRAME only exists in library mode (camera shows every frame WYSIWYG through + // the viewfinder overlays); WATERMARK is available in both modes. + mode: 'camera' | 'library'; } const TOOLS: { id: TabId; label: string; icon: LucideIcon }[] = [ @@ -16,9 +19,12 @@ const TOOLS: { id: TabId; label: string; icon: LucideIcon }[] = [ { id: 'wb', label: 'WB', icon: Thermometer }, { id: 'filters', label: 'FX', icon: Sparkles }, { id: 'frame', label: 'FRAME', icon: Frame }, + { id: 'watermark', label: 'WATERMARK', icon: Stamp }, ]; -export default function ToolRail({ activeTab, onToggle }: ToolRailProps) { +export default function ToolRail({ activeTab, onToggle, mode }: ToolRailProps) { + // FRAME (frame picker) belongs to library stills only: camera mode hides it. + const tools = TOOLS.filter((t) => t.id !== 'frame' || mode === 'library'); return ( - {TOOLS.map((tool) => { + {tools.map((tool) => { const isActive = activeTab === tool.id; const Icon = tool.icon; return ( diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 9dafaf9..368b015 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -81,6 +81,16 @@ interface ViewfinderProps { value: number; onChange: (v: number) => void; } | null; + // Custom text watermark (WATERMARK tab -> CUSTOM chip). Drawn live on the + // image like the GPS mark; dragging/tapping the image moves it. Fractions are + // 0..1 of the photo/window area. Absent prop = feature off. + customWm?: { + enabled: boolean; + text: string; + x: number; + y: number; + }; + onWmPositionChange?: (x: number, y: number) => void; } export interface ViewfinderHandle { @@ -151,6 +161,8 @@ const Viewfinder = forwardRef(function Viewfi meteringEvOffset = 0, meteringAdjustments, imageAdjustTarget, + customWm = { enabled: false, text: '', x: 0.5, y: 0.5 }, + onWmPositionChange, }: ViewfinderProps, ref ) { @@ -248,6 +260,12 @@ const Viewfinder = forwardRef(function Viewfi const adjustments = meteringAdjustments ? { ...recipe.adjustments, ...meteringAdjustments } : recipe.adjustments; + // Hardware AE bias (EV stops) currently sitting on the camera from the LAST + // capture flush (CameraX keeps it until the next change and it affects the + // live preview too). The preview matrix must render target − bias, otherwise + // after a highlight-weighted capture the preview double-darkens (the target + // EV again on top of the already-biased hardware). + const [hwBiasStops, setHwBiasStops] = useState(0); // Exposure compensation, applied as software 2^EV matrix gain for BOTH modes. // Camera AE bias is only flushed to hardware at capture time // (flushExposureBias): setting it live per slider value re-locks AE on the @@ -255,7 +273,14 @@ const Viewfinder = forwardRef(function Viewfi // Metering offset (highlight-weighted) rides on top of the user EV so the // preview shows the protected-exposure look; EV_RANGE ±3 caps the software // gain (flush clamps to the device range separately). - const evApplied = Math.max(-3, Math.min(3, (adjustments.exposureCompensation ?? 0) + meteringEvOffset)); + const clampEvApplied = (v: number) => Math.max(-3, Math.min(3, v)); + // Full EV the shot should carry (user EV + metering-mode offset). The + // software matrix renders target − hardware-bias while the camera is live — + // the hardware applies its bias to the preview itself, so adding the full + // target again would double it. Library stills (no hardware bias) and fresh + // camera sessions (bias reset to 0) render plain target. + const evTarget = clampEvApplied((adjustments.exposureCompensation ?? 0) + meteringEvOffset); + const evApplied = clampEvApplied(mode === 'camera' ? evTarget - hwBiasStops : evTarget); const colorMatrix = applyExposureGain( getSkiaColorMatrix(recipe.baseFilter, adjustments), evApplied @@ -312,6 +337,10 @@ const Viewfinder = forwardRef(function Viewfi watchdogBudgetRef.current = 0; setCameraActive(true); bumpSessionStart((n) => n + 1); + // A fresh CameraX session starts with the AE bias reset to 0 — mirror that + // so the preview compensation (target − bias) never subtracts a stale bias + // from the previous session. + setHwBiasStops(0); }, []); const handleCameraStopped = useCallback(() => { setCameraActive(false); @@ -416,6 +445,10 @@ const Viewfinder = forwardRef(function Viewfi const ev = evStopsRef.current + meteringEvOffsetRef.current; const index = Math.round(ev * 8); const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index)); + // Record the bias that just landed (or is already present) on the + // hardware: the preview subtracts it (target − bias) so the EV is never + // applied twice between captures. Index units / 8 = EV stops. + setHwBiasStops(clamped / 8); if (clamped === ctl.exposureBias) return; for (let attempt = 1; attempt <= 3; attempt++) { try { @@ -519,7 +552,7 @@ const Viewfinder = forwardRef(function Viewfi imageAdjustTargetRef.current = imageAdjustTarget; type ImgDragSession = { y0: number; - kind: 'param' | 'ev' | 'focus'; + kind: 'param' | 'ev' | 'wm' | 'focus'; v0: number; param: { min: number; max: number; step: number; onChange: (v: number) => void }; moved: boolean; @@ -535,6 +568,12 @@ 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). + kind = 'wm'; + const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; + placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); } else if (focusPoint && onExposureChange) { kind = 'ev'; v0 = evStops; @@ -544,6 +583,13 @@ const Viewfinder = forwardRef(function Viewfi const onImageDragMove = (e: any) => { const g = imgDragRef.current; 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); + g.moved = true; + return; + } const dy = g.y0 - pageYOf(e); // up = positive / increase if (!g.moved) { if (Math.abs(dy) < TAP_SLOP) return; @@ -572,6 +618,16 @@ const Viewfinder = forwardRef(function Viewfi const g = imgDragRef.current; imgDragRef.current = null; if (!g) return; + if (g.kind === 'wm') { + // A tap places the text at the tapped point; a drag already moved it. + // Never lock AE/AF while the watermark is armed. + if (!g.moved) { + const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; + placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); + } + commitWm(); + return; + } if (!g.moved) { // Plain tap: lock focus at the tapped point (drag never re-locks). handleTapFocus(e.nativeEvent.locationX, e.nativeEvent.locationY); @@ -886,6 +942,65 @@ const Viewfinder = forwardRef(function Viewfi return () => clearInterval(id); }, [mode, frameBeatSync, remountCameraSession]); + // ---- Custom text watermark: photo area, drag math, library overlay -------- + // Armed = CUSTOM chip ON + non-empty text + live position callback. While + // armed, touches on the image place/move the text (see the wm branches in the + // camera + library gesture handlers); this is the only way to position it. + const wmArmed = !!( + customWm.enabled && + customWm.text.trim().length > 0 && + onWmPositionChange + ); + const [wmDragPos, setWmDragPos] = 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 = () => { + const win = polaroidGeo?.window ?? wallGeo?.window ?? null; + if (win) return { dx: win.x, dy: win.y, dw: win.w, dh: win.h }; + if (regionBand) return { dx: region.x, dy: region.y, dw: region.w, dh: region.h }; + if (mode === 'library' && imageFitRect) return imageFitRect; + return { dx: 0, dy: 0, dw: vw, dh: vh }; + }; + const wmFontSize = Math.round(vw * 0.032); // matches customFont/iconFont size + const wmFracFromXY = (x: number, y: number) => { + const area = wmArea(); + const dw = area.dw || 1; + const dh = area.dh || 1; + return { + x: Math.max(0, Math.min(1, (x - area.dx) / dw)), + y: Math.max(0, Math.min(1, (y - area.dy) / dh)), + }; + }; + const placeWm = (x: number, y: number) => { + if (!wmArmed) return; + setWmDragPos(wmFracFromXY(x, y)); + }; + const commitWm = () => { + if (!wmArmed || !onWmPositionChange) return; + if (wmDragPos) onWmPositionChange(wmDragPos.x, wmDragPos.y); + setWmDragPos(null); + }; + // Library polaroid/wall returns have no pinch responder of their own — this + // fullscreen drag layer carries the watermark gesture there (drag = move, + // tap = place, lift = commit). Plain-library and camera modes reuse their + // existing gesture layers via the wm branches above. + const wmDragOverlay = ( + true} + onResponderGrant={(e: any) => { + const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; + placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); + }} + onResponderMove={(e: any) => { + const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; + placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); + }} + onResponderRelease={() => commitWm()} + onResponderTerminate={() => commitWm()} + /> + ); + const onLibTouchStart = (e: any) => { const ts = e.nativeEvent.touches; if (ts.length >= 2) { @@ -904,6 +1019,13 @@ const Viewfinder = forwardRef(function Viewfi }; const onLibTouchMove = (e: any) => { const ts = e.nativeEvent.touches; + // Watermark armed + one finger + unzoomed: the drag moves the text (not the + // photo). Zoomed-in keeps pinch/pan so the user can inspect detail first. + if (wmArmed && ts.length === 1 && libZoom.s <= 1.01) { + const t = ts[0]; + placeWm(t.pageX ?? t.locationX, t.pageY ?? t.locationY); + return; + } let p = pinchRef.current; if (ts.length >= 2 && !p) { // Responder grant fires once on the first finger; a second finger that @@ -939,6 +1061,13 @@ const Viewfinder = forwardRef(function Viewfi if (wasPinch) return; // a double-tap is always a single finger const x = e.nativeEvent.locationX; const y = e.nativeEvent.locationY; + // 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) { + placeWm(x, y); + commitWm(); + return; + } const now = Date.now(); const prev = lastTapRef.current; lastTapRef.current = { x, y, t: now }; @@ -1073,6 +1202,20 @@ const Viewfinder = forwardRef(function Viewfi return null; }; + const renderCustomWatermark = () => { + if (!wmArmed || !customFont) return null; + const area = wmArea(); + const fracX = wmDragPos?.x ?? customWm.x; + const fracY = wmDragPos?.y ?? customWm.y; + // Baseline = dragged anchor + ~0.35em drop (the grabbed anchor reads as the + // text's visual center). Must match exportEngine step 8's formula. + const tx = area.dx + fracX * area.dw; + const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35); + return ( + + ); + }; + const renderGPSWatermark = () => { if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null; @@ -1090,13 +1233,7 @@ const Viewfinder = forwardRef(function Viewfi // 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 = win - ? { dx: win.x, dy: win.y, dw: win.w, dh: win.h } - : regionBand - ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } - : mode === 'library' && imageFitRect - ? imageFitRect - : { dx: 0, dy: 0, dw: vw, dh: vh }; + 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); @@ -1154,6 +1291,7 @@ const Viewfinder = forwardRef(function Viewfi )} {renderFrameOverlay()} {renderGPSWatermark()} + {renderCustomWatermark()} {/* Rule-of-thirds guide, clipped to the composition region (fullscreen @@ -1395,7 +1533,9 @@ const Viewfinder = forwardRef(function Viewfi strokeWidth={1} /> {renderGPSWatermark()} + {renderCustomWatermark()} + {wmArmed && wmDragOverlay} ); } @@ -1457,7 +1597,9 @@ const Viewfinder = forwardRef(function Viewfi {renderGPSWatermark()} + {renderCustomWatermark()} + {wmArmed && wmDragOverlay} ); } @@ -1547,6 +1689,7 @@ const Viewfinder = forwardRef(function Viewfi {renderFrameOverlay()} {renderGPSWatermark()} + {renderCustomWatermark()} CUSTOM WATERMARK chip): enabled flag, +// the typed text, and its anchor position as 0..1 fractions of the photo area +// (0.5/0.5 = centered). The anchor is the text's BASELINE plus a small 0.35em +// drop in both renderers — keep the formula in sync across Viewfinder + +// exportEngine. +export interface CustomWatermark { + enabled: boolean; + text: string; + x: number; + y: number; +} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index a182ecc..de238ac 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -34,6 +34,10 @@ export interface ExportOptions { // viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed // opening and export size). 'full'/undefined = no crop. aspect?: AspectRatio; + // Custom text watermark: typed text + 0..1 fractions of the photo area + // (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay, + // so the export lands where the preview showed it). Amber mono, no icons. + watermark?: { text: string; x: number; y: number } | null; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 @@ -407,8 +411,11 @@ export async function processAndExportPhoto( drawFrameOnCanvas(canvas, width, height, frameId); } - // 8. Render GPS Geotag Watermark - if (useGeotag && gpsInfo) { + // 8. Render GPS Geotag Watermark + Custom Text Watermark. One asset load + + // one amber-mono paint path serves both: GPS keeps its two icon lines; the + // custom watermark is plain text drawn at the user-dragged position. + const customWm = options?.watermark ?? null; + if ((useGeotag && gpsInfo) || customWm) { const fontAsset = Asset.fromModule(require('../../assets/Cousine-Regular.ttf')); const iconFontAsset = Asset.fromModule(require('../../assets/NotoEmoji-GPS.ttf')); await Promise.all([fontAsset.downloadAsync(), iconFontAsset.downloadAsync()]); @@ -428,28 +435,44 @@ export async function processAndExportPhoto( const iconPaint = Skia.Paint(); iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text - 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) + // GPS lines keep the historical bottom-left anchor (framed -> inside + // the photo window; other frames -> bottom-left of the image). + 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) - // Framed (polaroid/wallframe): caption at the bottom-left inside the - // photo window. Other frames: bottom-left of the image, clear of edge. - 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; + 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; + } + + 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); } - 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); + // Custom watermark: baseline at the dragged fraction of the photo/window + // area + ~0.35em drop (the grabbed anchor reads as the text's visual + // center, not its baseline) — the exact formula Viewfinder uses, so + // preview == export. + if (customWm) { + const area = framedWindow + ? { dx: framedWindow.x, dy: framedWindow.y, dw: framedWindow.w, dh: framedWindow.h } + : { dx: 0, dy: 0, dw: stampW, dh: stampH }; + const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + const tx = area.dx + clamp01(customWm.x) * area.dw; + const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(fontSize * 0.35); + stampCanvas.drawText(customWm.text, tx, ty, textPaint, font); + } } } diff --git a/src/utils/nativeExport.ts b/src/utils/nativeExport.ts index 72470ac..c520b4f 100644 --- a/src/utils/nativeExport.ts +++ b/src/utils/nativeExport.ts @@ -32,6 +32,11 @@ export interface NativeExportOptions { // Composition aspect-ratio crop ('full'/undefined = none). Native computes the // center-largest rect after decode because the pixel size is only known there. aspect?: AspectRatio; + // Custom text watermark overlay — position fractions of the photo area, same + // contract as ExportOptions.watermark. SHIPPED but NOT drawn yet: the Kotlin + // module renders geo captions but has no text-watermark draw (ponytail: add + // it when native parity with the Skia engine is completed). + watermark?: { text: string; x: number; y: number } | null; } // The Kotlin module reads plain absolute paths, not file:// URIs.