Add WATERMARK tab with custom draggable text; hide FRAME in camera; fix double-dark after highlight capture
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user