Add WATERMARK tab with custom draggable text; hide FRAME in camera; fix double-dark after highlight capture

This commit is contained in:
2026-09-10 07:17:14 +07:00
parent 2512e05c92
commit 144b211d35
7 changed files with 315 additions and 42 deletions
+49 -5
View File
@@ -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<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(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<CustomWatermark>({ ...DEFAULT_CUSTOM_WM });
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('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<CustomWatermark>;
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 }))}
/>
</View>
@@ -872,11 +914,13 @@ export default function App() {
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
customWm={customWm}
onUpdateCustomWm={setCustomWm}
/>
</View>
)}
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
<ToolRail activeTab={openTab} onToggle={handleToggleTab} mode={mode} />
<CameraControls
lastPhotoUri={lastPhotoUri}
+43 -3
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Trash2 } from 'lucide-react-native';
import { Recipe, ColorAdjustments, FrameId, BaseFilter } from '../types';
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
@@ -15,7 +15,7 @@ import RecipeCreateModal from './RecipeCreateModal';
// Tapping a parameter chip toggles its slider row; chips stay visible.
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey = 'cx' | 'cxb' | 'dr';
@@ -46,6 +46,10 @@ interface AdjustmentPanelProps {
onToggleGeotag: (enabled: boolean) => 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)
)}
</ScrollView>
);
case 'watermark':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{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 }),
})}
</ScrollView>
);
@@ -516,6 +539,23 @@ export default function AdjustmentPanel({
</ScrollView>
</View>
)}
{activeTab === 'watermark' && customWm.enabled && (
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
<TextInput
value={customWm.text}
onChangeText={(text) => 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}
/>
<Text className="mt-1 font-mono text-[9px] font-bold tracking-widest text-amber-400/80">
{customWm.text.trim().length > 0
? 'DRAG ON IMAGE TO MOVE — TAP TO PLACE'
: 'TYPE TEXT ABOVE, THEN DRAG ON IMAGE TO PLACE'}
</Text>
</View>
)}
{chipsRow}
<RecipeCreateModal
visible={createVisible}
+10 -4
View File
@@ -1,13 +1,16 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, type LucideIcon } from 'lucide-react-native';
import { LayoutGrid, Sun, Thermometer, Sparkles, Frame, Stamp, type LucideIcon } from 'lucide-react-native';
import * as Haptics from 'expo-haptics';
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
interface ToolRailProps {
activeTab: TabId | null;
onToggle: (tab: TabId) => 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 (
<View className="py-1.5">
<ScrollView
@@ -26,7 +32,7 @@ export default function ToolRail({ activeTab, onToggle }: ToolRailProps) {
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8 }}
>
{TOOLS.map((tool) => {
{tools.map((tool) => {
const isActive = activeTab === tool.id;
const Icon = tool.icon;
return (
+152 -9
View File
@@ -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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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 = (
<View
className="absolute inset-0"
onStartShouldSetResponder={() => 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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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 (
<SkiaText x={tx} y={ty} text={customWm.text.trim()} font={customFont} color="#f59e0b" />
);
};
const renderGPSWatermark = () => {
if (!useGeotag || !gpsInfo || !customFont || !iconFont) return null;
@@ -1090,13 +1233,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
</Canvas>
{/* Rule-of-thirds guide, clipped to the composition region (fullscreen
@@ -1395,7 +1533,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
strokeWidth={1}
/>
{renderGPSWatermark()}
{renderCustomWatermark()}
</Canvas>
{wmArmed && wmDragOverlay}
</View>
);
}
@@ -1457,7 +1597,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<SkiaImage image={wallImage} x={0} y={0} width={WALLFRAME_PNG_W} height={WALLFRAME_PNG_H} fit="fill" />
</Group>
{renderGPSWatermark()}
{renderCustomWatermark()}
</Canvas>
{wmArmed && wmDragOverlay}
</View>
);
}
@@ -1547,6 +1689,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<Group matrix={libZoomMatrix}>
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
<View
+12
View File
@@ -76,3 +76,15 @@ export interface GPSInfo {
timestamp: number;
source?: 'exif' | 'mediaLibrary' | 'device'; // where the GPS data came from
}
// Custom text watermark (WATERMARK tab -> 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;
}
+44 -21
View File
@@ -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);
}
}
}
+5
View File
@@ -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.