588fbf77d9
Measured on the 12S Ultra (2203121C), same scene, 1x, EV 0, 3 shots per arm:
- qualityPrioritization 'quality' -> 'balanced': tap->file 1013 ms vs 736 ms
(-277 ms, -27%), settled 1552 ms vs 1280 ms, in-camera 521 ms vs 286 ms.
Repeat on a second session: -228 ms. Image quality at ISO ~450 is inside the
scatter of one arm (flat sigma +2.4%, edge +0.1%, texture -1.1%), so the MFNR
pass quality would have bought is not measurable here. Also removes the
EXPO_PUBLIC_CAPTURE_MODE seam: Metro inlines EXPO_PUBLIC_* in dev only, the
release HBC build does not, so it could never gate shipped behaviour.
- Dark scenes now go through the app's own burst (ISO >= 100 gate for the probe)
and mergeBurst runs in the export queue instead of on the shutter path, so a
second shot is accepted while the frames are still being averaged: double-tap
at 1.2 s used to drop the second photo, now it lands. Output stays the merged
frame (flat sigma 0.825 vs 0.939 for a single frame).
Burst is skipped when the LED is the light source, when the shot came from the
native 0.5x session, for a held burst, for RAW, and below the devicePerf RAM
floor (deviceFloor.ts) where three raws plus the alignment rasters would be
seconds of shutter lag.
Bundles in the work this sits on: frame averaging (burstAlign/burstMerge),
the per-device floor, and the EXIF/ISO readback the burst gate needs (exifWrite,
photoMeta, exportEngine, RecipescamExportModule).
Self-checks: src/utils/{burstAlign,deviceFloor,exifWrite}.check.ts.
2231 lines
100 KiB
TypeScript
2231 lines
100 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native';
|
|
import { useCameraDevice, useCameraPermission, usePhotoOutput } from 'react-native-vision-camera';
|
|
import * as DocumentPicker from 'expo-document-picker';
|
|
import * as Sharing from 'expo-sharing';
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import * as MediaLibraryLegacy from 'expo-media-library/legacy';
|
|
import * as Haptics from 'expo-haptics';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
|
|
import TopBar from './src/components/TopBar';
|
|
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
|
|
import AdjustmentPanel, { HslPickPanel } from './src/components/AdjustmentPanel';
|
|
import CameraControls from './src/components/CameraControls';
|
|
import ToolRail, { TabId } from './src/components/ToolRail';
|
|
import SettingsModal from './src/components/SettingsModal';
|
|
import PhotoViewerModal from './src/components/PhotoViewerModal';
|
|
import LibraryPickerModal from './src/components/LibraryPickerModal';
|
|
import QrScanModal from './src/components/QrScanModal';
|
|
import ProModal from './src/components/ProModal';
|
|
|
|
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, HslBand, HslBandId } from './src/types';
|
|
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
|
|
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
|
|
import { HSL_BANDS, nearestHslBand, rgbToHsl } from './src/utils/colorUtils';
|
|
import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils';
|
|
// Types are erased at build time, so this one is free.
|
|
import type { PickedPhotoAsset } from './src/utils/photoGps';
|
|
import { PARAM_DEFS } from './src/utils/paramDefs';
|
|
import { LITE_FAVORITE_LIMIT, LITE_MARK, LITE_RECIPE_LIMIT, proLookInUse } from './src/utils/entitlement';
|
|
import { initProUnlock, useProUnlock } from './src/utils/proUnlock';
|
|
import { readJpegIso } from './src/utils/exifWrite';
|
|
import { readPhotoBytes, devicePerf } from './src/utils/photoMeta';
|
|
import { BURST_FRAMES, BURST_MIN_ISO, mergeBurst } from './src/utils/burstMerge';
|
|
import { canBurst } from './src/utils/deviceFloor';
|
|
import UltraWide from './modules/recipescam-ultrawide';
|
|
import RecipescamExport from './modules/recipescam-export';
|
|
import VolumeShutter from './modules/recipescam-volume';
|
|
|
|
// @ts-ignore
|
|
import './global.css';
|
|
|
|
// Shallow value diff over the union of both objects' keys: undefined and a
|
|
// missing key both read as "no value", so an old session without the newer
|
|
// optional knobs is not reported as an edit.
|
|
const differs = <T extends object>(a: T, b: T) => {
|
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof T>;
|
|
return [...keys].some((k) => (a[k] ?? null) !== (b[k] ?? null));
|
|
};
|
|
|
|
const SESSION_KEY = 'saved_session';
|
|
const STARTUP_MODE_KEY = 'startup_mode';
|
|
// Camera/device settings that survive restarts regardless of startup mode
|
|
// (the session file only restores the full look under 'last session').
|
|
const CAMERA_SETTINGS_KEY = 'camera_settings';
|
|
|
|
// P5: native Kotlin export path. EXPO_PUBLIC_NATIVE_EXPORT=1 swaps the Skia
|
|
// render engine for the RecipesCamExport native pipeline (same look contract,
|
|
// rendering off the JS thread). Default stays legacy until P6 parity/perf gate
|
|
// decides which engine ships.
|
|
const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1';
|
|
|
|
// How long a shutter may wait for a GPS fix before exporting without it. The
|
|
// photo is never held hostage by the receiver: indoors a Balanced fix takes
|
|
// 20s+ (measured 25s on the V2430), which is the whole reason a capture used to
|
|
// take half a minute to reach the gallery. The next shot picks the fix up.
|
|
const GPS_WAIT_MS = 2000;
|
|
|
|
// The photo pipeline (Skia, the frame pack, the filter maths) and the recipe
|
|
// XML codec run behind a shutter press or a share sheet, never on the first
|
|
// frame, so they are loaded on demand instead of sitting in the cold-start
|
|
// module graph. Same two functions the eager imports used to bind; wait: only
|
|
// the *renderers* moved — nothing about an export changed.
|
|
const loadRenderer = (native: boolean) =>
|
|
native
|
|
? import('./src/utils/nativeExport').then((m) => m.processAndExportPhotoNative)
|
|
: import('./src/utils/exportEngine').then((m) => m.processAndExportPhoto);
|
|
|
|
export default function App() {
|
|
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
|
// Back/front lens the live viewfinder uses; flipping is transient UI state
|
|
// (not persisted in sessions — startup always opens on the back camera).
|
|
const [cameraFacing, setCameraFacing] = useState<'back' | 'front'>('back');
|
|
// Imperative handle into the Viewfinder camera controller: reads the live
|
|
// viewfinder state at capture/export time (framed-window zoom, crop rect,
|
|
// horizon). There is no EV flush to do: the hardware AE bias is never
|
|
// touched — setExposureBias re-locks AE and flashes the Xiaomi preview.
|
|
const viewfinderRef = useRef<ViewfinderHandle>(null);
|
|
// Edge-to-edge (app.json/gradle edgeToEdgeEnabled) makes Android ignore the
|
|
// manifest's adjustResize, so the OS never shrinks the window for the IME and
|
|
// the bottom deck stays under the keyboard. Track the IME height ourselves and
|
|
// lift the deck by it (covers the custom-watermark text input).
|
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
// Top edge of the bottom deck (its own onLayout) -> the height left for the
|
|
// photo. Library mode letterboxes the image above it so no watermark
|
|
// position hides under an open panel. Null until the first layout pass.
|
|
const [deckTop, setDeckTop] = useState<number | null>(null);
|
|
// Bottom edge of the floating top strip -> the library photo starts there; a
|
|
// ratio band peeking under the header is a band the user cannot see.
|
|
const [topInset, setTopInset] = useState(0);
|
|
// Library photo rotation (FRAME tab): the quarter turn in degrees clockwise
|
|
// plus the fine STRAIGHTEN offset. Both feed the preview source and the
|
|
// export alike, the straighten angle applied on top of the turn.
|
|
const [photoRotation, setPhotoRotation] = useState<0 | 90 | 180 | 270>(0);
|
|
const [photoStraighten, setPhotoStraighten] = useState(0);
|
|
useEffect(() => {
|
|
const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height));
|
|
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0));
|
|
return () => {
|
|
show.remove();
|
|
hide.remove();
|
|
};
|
|
}, []);
|
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
|
// LITE (free) vs PRO: the PRO apk is born owned, the free one buys the tier
|
|
// through Google Play. Runtime state, read here and passed down.
|
|
const pro = useProUnlock().owned;
|
|
// The paywall sheet: opened by the SETTINGS plan row and by every alert that
|
|
// names a PRO feature, so the refusal always comes with a way to pay.
|
|
const [showPro, setShowPro] = useState(false);
|
|
// Ask the store what this account owns, once, at startup. Until it answers,
|
|
// the cached answer stands (see src/utils/proUnlock.ts).
|
|
useEffect(() => {
|
|
initProUnlock();
|
|
}, []);
|
|
// Lite stores up to LITE_RECIPE_LIMIT recipes of its own; past that the save
|
|
// paths ask for PRO. Deleting one is PRO as well, so a Lite list only grows.
|
|
const customRecipeCount = recipes.filter((r) => r.isCustom).length;
|
|
const liteSaveBlocked = !pro && customRecipeCount >= LITE_RECIPE_LIMIT;
|
|
const promptRecipeLimit = () => promptProFeature(`Saving more than ${LITE_RECIPE_LIMIT} recipes`);
|
|
// FAVORITED: ids of the starred recipes. Metadata only — never saved into the
|
|
// session look, so RESET and applying a recipe leave it untouched.
|
|
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
|
|
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
|
// Custom recipe open in the parameter editor (the CREATE form in edit mode).
|
|
const [editingRecipe, setEditingRecipe] = useState<Recipe | null>(null);
|
|
|
|
// Custom temporary overrides for the sliders
|
|
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
|
|
|
|
// HSL mixer: the band ruler and the eyedropper are shared by the chip row
|
|
// (AdjustmentPanel) and by the pick layer over the picture (Viewfinder), so
|
|
// they live here.
|
|
const [hslBand, setHslBand] = useState<HslBandId>('red');
|
|
const [picking, setPicking] = useState(false);
|
|
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
|
|
// Where on the picture the sample was read: fractions of the image, the
|
|
// floating panel's anchor.
|
|
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null);
|
|
|
|
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
|
|
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
|
|
// it and `handleToggleGeotag` refuses to turn it on.
|
|
const [useGeotag, setUseGeotag] = useState(pro);
|
|
// GPS master switch (SETTINGS -> GPS): may the app read the device's
|
|
// location at all? ON resolves a fix at capture and reverse-geocodes it;
|
|
// OFF reads nothing. The GPS WATERMARK chip is separate — it only decides
|
|
// whether a resolved location is printed on the photo.
|
|
const [gpsEnabled, setGpsEnabled] = 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 });
|
|
// 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,
|
|
color: '#f59e0b',
|
|
showName: true,
|
|
showTime: true,
|
|
};
|
|
const [gpsWm, setGpsWm] = useState<GpsWatermark>({ ...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).
|
|
const [customWmApplied, setCustomWmApplied] = useState<CustomWatermark>({
|
|
...DEFAULT_CUSTOM_WM,
|
|
});
|
|
// Bumped by a double-tap on the mark: the panel re-focuses the text input.
|
|
const [wmEditNonce, setWmEditNonce] = useState(0);
|
|
// Draft edit: toggling the chip OFF also drops any accepted stamp, so a
|
|
// switched-off watermark can never come back in an export.
|
|
const handleUpdateCustomWm = (wm: CustomWatermark) => {
|
|
// Typing coalesces into one step per pause; a chip (colour, size, ON/OFF)
|
|
// is a step of its own even when two of them land back to back.
|
|
commit(wm.text !== customWm.text, () => {
|
|
setCustomWm(wm);
|
|
if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
});
|
|
};
|
|
// Leaving the watermark editor = closing its panel. The mark keeps capturing
|
|
// image touches ONLY while that panel is open (Viewfinder's wmArmed), so this
|
|
// is what hands the photo back to tap-to-focus / pinch / double-tap zoom.
|
|
const closeWatermarkEditor = () => {
|
|
setOpenTab(null);
|
|
setOpenParamKey(null);
|
|
};
|
|
// ACCEPT: the draft becomes the mark the export burns in, then the editor
|
|
// closes so the image stops being a placement surface.
|
|
const handleAcceptCustomWm = () => {
|
|
commit(false, () => setCustomWmApplied({ ...customWm }));
|
|
closeWatermarkEditor();
|
|
};
|
|
// CANCEL: drop every un-accepted edit and fall back to the last ACCEPTED mark
|
|
// (none at all when nothing was ever accepted), then close exactly like
|
|
// ACCEPT — the escape hatch a running session previously lacked.
|
|
const handleCancelCustomWm = () => {
|
|
commit(false, () => setCustomWm({ ...customWmApplied }));
|
|
closeWatermarkEditor();
|
|
};
|
|
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
|
|
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
|
|
// FRAME-tab CROP (library plain frames). 'free' keeps an explicit rectangle
|
|
// the Viewfinder lets the user drag; fixed ratios center-crop. Both are
|
|
// handed to the export so the file matches the preview.
|
|
const [cropRatio, setCropRatio] = useState<CropRatio>('none');
|
|
// CROP is a two-step tool: the ratio strip only FRAMES the band (amber
|
|
// outline + dim surround) until APPLY commits it, after which the viewer
|
|
// shows the crop alone. Holds the committed ratio, so picking another ratio
|
|
// (or RESET) drops straight back to the framing state.
|
|
const [cropApplied, setCropApplied] = useState<CropRatio | null>(null);
|
|
const [cropRect, setCropRect] = useState<CropRect>(DEFAULT_CROP_RECT);
|
|
const [rawEnabled, setRawEnabled] = useState(false);
|
|
// Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters
|
|
// the BRIGHTEST area to protect its detail (never blow whites) and pushes the
|
|
// shadows very deep — extreme contrast (GR/Sony). Realised as: a small EV dip
|
|
// (capture headroom; NOT a full-frame dim), a highlight roll (keep bright
|
|
// detail), deep shadow crush and raised contrast/clarity. All constants are a
|
|
// starting approximation — tune after device testing.
|
|
const [meterMode, setMeterMode] = useState<MeterMode>('evaluative');
|
|
const METER_HL_EV = -0.5;
|
|
const METER_HL_HIGHLIGHT = -3;
|
|
const METER_HL_SHADOW = -4;
|
|
const METER_HL_CONTRAST = 3;
|
|
const METER_HL_CLARITY = 3;
|
|
const clampAdj = (v: number) => Math.max(-10, Math.min(10, v));
|
|
const meteringActive = mode === 'camera' && meterMode === 'highlight';
|
|
// The overlay is built FROM the adjustments it is laid on, so the preview can
|
|
// ask for the pre-edit one while the finger holds the compare. Built from the
|
|
// live values it always was, it kept painting the EDITED look back over the
|
|
// recipe the peek had swapped in, and neither HIGHLIGHT nor SHADOW (nor EV,
|
|
// CONTRAST, CLARITY) ever came back.
|
|
const meteringOverlay = (a: ColorAdjustments): Partial<ColorAdjustments> => ({
|
|
exposureCompensation: clampAdj((a.exposureCompensation ?? 0) + METER_HL_EV),
|
|
highlight: clampAdj((a.highlight ?? 0) + METER_HL_HIGHLIGHT),
|
|
shadow: clampAdj((a.shadow ?? 0) + METER_HL_SHADOW),
|
|
contrast: clampAdj((a.contrast ?? 0) + METER_HL_CONTRAST),
|
|
clarity: clampAdj((a.clarity ?? 0) + METER_HL_CLARITY),
|
|
});
|
|
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
|
|
? meteringOverlay(adjustments)
|
|
: undefined;
|
|
// Same overlay for the camera EXPORT — the EV dip included: EV is software
|
|
// only (no hardware AE bias anywhere), so preview and file must both apply it.
|
|
const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments;
|
|
const rawFailRef = useRef(false);
|
|
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
|
|
const [openTab, setOpenTab] = useState<TabId | null>(null);
|
|
// The parameter whose slider row is open inside the panel — lifted here (not
|
|
// local to AdjustmentPanel) because an open row arms drag-anywhere-on-the-
|
|
// image adjustment in the Viewfinder. Cleared when the panel/tab closes.
|
|
const [openParamKey, setOpenParamKey] = useState<string | null>(null);
|
|
// FRAME → WATERMARK sub-panel is open (the rail's WATERMARK tab is gone).
|
|
const [wmPanelOpen, setWmPanelOpen] = useState(false);
|
|
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
|
|
// Session photo history (camera captures + library exports). The camera
|
|
// thumbnail/preview button shows the newest entry; the swipe viewer walks the
|
|
// whole list. Not persisted across restarts — entries are app-cache files.
|
|
const [photoHistory, setPhotoHistory] = useState<PhotoEntry[]>([]);
|
|
// Index into photoHistory of the photo shown in the swipe viewer; null = closed.
|
|
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
|
// In-app library picker (grid -> full-screen preview -> USE/CANCEL).
|
|
const [libraryPickerVisible, setLibraryPickerVisible] = useState(false);
|
|
// QR sheet: reads a recipe link off a screen, so the viewfinder camera behind
|
|
// it is paused while it is up (one lens, two camera libraries).
|
|
const [scanVisible, setScanVisible] = useState(false);
|
|
// Gear sheet (sound / ratio / metering mode). Camera captures never auto-open
|
|
// the viewer anymore — the thumbnail button does.
|
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
|
// Shutter click: only OFF/ON (no volume API on Android). Applied at capture.
|
|
const [shutterSound, setShutterSound] = useState<ShutterSound>('on');
|
|
// LED flash mode. Capture-time only (no live torch): 'off' keeps today's
|
|
// behaviour, 'auto' lets the pipeline decide, 'on' always fires. Applied to
|
|
// the still, so the preview itself never changes brightness.
|
|
const [flashMode, setFlashMode] = useState<FlashMode>('off');
|
|
// Touch-to-shutter (gear sheet): a plain tap on the live viewfinder locks
|
|
// AE/AF/AWB on the tapped point and, once focus has settled, fires a capture.
|
|
// OFF = a tap only focuses (the historical behaviour).
|
|
const [touchToShutter, setTouchToShutter] = useState(false);
|
|
// Startup behavior: 'default' = PROVIPES neutral at 4:3 camera; 'last' = restore
|
|
// previous session from AsyncStorage. Default is 'default'.
|
|
const [startupMode, setStartupMode] = useState<StartupMode>('default');
|
|
const photoSeq = useRef(0);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
// Short shutter-burst lock: guards the capturePhotoToFile calls from
|
|
// overlapping. Released as soon as the frames are written — NOT after the
|
|
// render, so a new shot can start while the previous photo is still being
|
|
// processed in the background.
|
|
const captureBusyRef = useRef(false);
|
|
// Cold-start shutter gate: the Viewfinder reports when the CameraX session
|
|
// is actually bound (onStarted/onStopped). A shutter press landing before
|
|
// that is QUEUED, not dropped — the built-in camera answers the first tap
|
|
// too, and a press into a dead session produced no file at all.
|
|
const cameraReadyRef = useRef(false);
|
|
const pendingShotRef = useRef(false);
|
|
// FIFO background render queue: processAndExportPhoto (Skia render + gallery
|
|
// save, ~1s) runs OFF the shutter path, one at a time, so the next photo can
|
|
// be taken immediately. FIFO keeps history order == shutter order. Renders
|
|
// are heavy on the JS thread but yield between awaits — UI keeps responding.
|
|
const exportQueueRef = useRef<Promise<void>>(Promise.resolve());
|
|
const enqueueExport = (task: () => Promise<void>) => {
|
|
exportQueueRef.current = exportQueueRef.current
|
|
.catch(() => {}) // a failed render must not stall the queue
|
|
.then(task)
|
|
.catch((e) => console.error('Background export failed:', e));
|
|
};
|
|
|
|
// Hardware Permissions (vision-camera)
|
|
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
|
|
// The lens the flash control belongs to. CameraX throws when asked for
|
|
// 'on'/'auto' on a device with no flash unit (the front lens, most tablets),
|
|
// so the control is only offered while this reads true.
|
|
const flashAvailable = useCameraDevice(cameraFacing)?.hasFlash ?? false;
|
|
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
|
|
// Photo capture is resolution-independent from the Skia preview output.
|
|
const photoOutput = usePhotoOutput({
|
|
// LATENCY, measured: 'balanced' is CAPTURE_MODE_MINIMIZE_LATENCY (one
|
|
// frame, no CameraX still-image post-processing); 'quality' is
|
|
// CAPTURE_MODE_MAXIMIZE_QUALITY, which adds in-camera multi-frame noise
|
|
// reduction plus reprocessing. Same scene at ISO ~410 on the 12S Ultra,
|
|
// 3 shots per arm: in-camera START->onImageCaptured 291 ms vs 499 ms,
|
|
// tap->file on disk 797 ms vs 1025 ms (-22%), and the two JPEG sets were
|
|
// indistinguishable (flat-area sigma 1.08 vs 1.09, edge energy -3%).
|
|
// Dark scenes are handled by this app's own burst + merge below (ISO >=
|
|
// 800), so CameraX MFNR would only ever be a second, redundant pass.
|
|
// Revert to 'quality' if a high-ISO A/B ever shows merge losing to MFNR.
|
|
qualityPrioritization: 'balanced',
|
|
});
|
|
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
|
|
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
|
|
// use cases, so attaching both at once would break the session. Attaching a
|
|
// DNG output while RAW is on gives ~one exposure per capture: the JPEG below
|
|
// and this DNG are separate shots taken a few tens of ms apart.
|
|
const rawPhotoOutput = usePhotoOutput({
|
|
containerFormat: 'dng',
|
|
qualityPrioritization: 'quality',
|
|
});
|
|
|
|
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
|
|
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
|
|
|
|
// Resolved by the mount effect just below, awaited by the share intake further
|
|
// down: a share that cold-starts the app must land AFTER the session restore,
|
|
// or the restored libraryImageUri/mode would replace the shared photo — and by
|
|
// then the intent is consumed, so the photo is unreachable for good.
|
|
const sessionInitRef = useRef<Promise<void> | null>(null);
|
|
|
|
useEffect(() => {
|
|
// B5: run a single sequenced init — restore the session first, then fall
|
|
// back to the first recipe only when no recipe was restored.
|
|
// The restored look is step zero: BACK can never walk behind the load.
|
|
sessionInitRef.current = initSession().then(clearHistory);
|
|
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
|
|
}, []);
|
|
|
|
// A photo picked while the GPS chip was OFF kept its coordinates but no place
|
|
// name (the geocode only ran when the chip was already on), and toggleGeotag
|
|
// skips GPS loading entirely when coordinates exist — so turning the chip on
|
|
// showed "STREET VIEW". Resolve the name whenever the mark needs one.
|
|
useEffect(() => {
|
|
if (!gpsEnabled || !gpsInfo || gpsInfo.locality) return;
|
|
let stale = false;
|
|
reverseGeocode(gpsInfo.latitude, gpsInfo.longitude).then((name) => {
|
|
if (!stale && name) setGpsInfo((g) => (g && !g.locality ? { ...g, locality: name } : g));
|
|
});
|
|
return () => {
|
|
stale = true;
|
|
};
|
|
}, [gpsEnabled, gpsInfo]);
|
|
|
|
// Warm the fix up at launch instead of at the shutter. A fix taken here is
|
|
// what the first photo stamps, so the capture path normally has nothing to
|
|
// wait for — the same head start the built-in camera gets from its own
|
|
// always-on location client. GPS_WAIT_MS below is the backstop when it is not
|
|
// ready yet.
|
|
useEffect(() => {
|
|
if (!gpsEnabled) return;
|
|
let stale = false;
|
|
getCurrentGPS().then((info) => {
|
|
if (!stale && info) setGpsInfo((g) => g ?? { ...info, source: 'device' as const });
|
|
});
|
|
return () => {
|
|
stale = true;
|
|
};
|
|
}, [gpsEnabled]);
|
|
|
|
// Save/restore session on app lifecycle changes
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
|
if (nextAppState === 'background' || nextAppState === 'inactive') {
|
|
const session = {
|
|
mode,
|
|
libraryImageUri,
|
|
adjustments,
|
|
selectedFrame,
|
|
useGeotag,
|
|
aspectRatio,
|
|
cropRatio,
|
|
cropRect,
|
|
cropApplied,
|
|
meterMode,
|
|
shutterSound,
|
|
selectedRecipeId: selectedRecipe?.id || null,
|
|
gpsInfo,
|
|
customWm,
|
|
gpsWm,
|
|
};
|
|
try {
|
|
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
|
} catch (e) {
|
|
console.warn('Failed to save session:', e);
|
|
}
|
|
}
|
|
});
|
|
return () => subscription.remove();
|
|
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, cropRatio, cropRect, cropApplied, meterMode, shutterSound]);
|
|
|
|
// Guards the camera-settings persist effect below: until initSession has
|
|
// read the stored values, changes are just the initial defaults — writing
|
|
// them would overwrite the saved settings (async restore loses the race
|
|
// against the mount effect).
|
|
const settingsHydratedRef = useRef(false);
|
|
|
|
const initSession = async () => {
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
setFavoriteIds(await getFavoriteIds());
|
|
|
|
// Device/camera settings restore first: they apply under BOTH startup
|
|
// modes ('default' resets only the look, not these hardware choices).
|
|
try {
|
|
const raw = await AsyncStorage.getItem(CAMERA_SETTINGS_KEY);
|
|
if (raw) {
|
|
const s = JSON.parse(raw);
|
|
if (s.shutterSound === 'on' || s.shutterSound === 'off') setShutterSound(s.shutterSound);
|
|
if (s.flashMode === 'off' || s.flashMode === 'auto' || s.flashMode === 'on') setFlashMode(s.flashMode);
|
|
if (s.meterMode === 'evaluative' || s.meterMode === 'highlight') setMeterMode(s.meterMode);
|
|
if (typeof s.rawEnabled === 'boolean') setRawEnabled(s.rawEnabled);
|
|
if (typeof s.gpsEnabled === 'boolean') setGpsEnabled(s.gpsEnabled);
|
|
if (typeof s.touchToShutter === 'boolean') setTouchToShutter(s.touchToShutter);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to read camera settings:', e);
|
|
}
|
|
settingsHydratedRef.current = true;
|
|
|
|
// Startup mode: 'default' opens camera + PROVIPES film sim (neutral
|
|
// adjustments, DEFAULT_ADJUSTMENTS) at 4:3. 'last' restores the previous
|
|
// session from AsyncStorage; with no saved session it falls back to the
|
|
// same clean default state.
|
|
let startup: StartupMode = 'default';
|
|
try {
|
|
const raw = await AsyncStorage.getItem(STARTUP_MODE_KEY);
|
|
if (raw === 'last') startup = 'last';
|
|
} catch (e) {
|
|
console.warn('Failed to read startup mode:', e);
|
|
}
|
|
setStartupMode(startup);
|
|
|
|
const applyDefaultStartup = () => {
|
|
setMode('camera');
|
|
setLibraryImageUri(null);
|
|
setAspectRatio('4:3');
|
|
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
|
if (provia) applyRecipe(filmSimToRecipe(provia));
|
|
// Clean camera start: GPS watermark stays OFF until the user opts in.
|
|
setUseGeotag(false);
|
|
};
|
|
|
|
if (startup === 'default') {
|
|
applyDefaultStartup();
|
|
return;
|
|
}
|
|
|
|
let session: any = null;
|
|
try {
|
|
const raw = await AsyncStorage.getItem(SESSION_KEY);
|
|
if (raw) session = JSON.parse(raw);
|
|
} catch (e) {
|
|
console.warn('Failed to read session:', e);
|
|
}
|
|
|
|
let restoredRecipe: Recipe | null = null;
|
|
if (session) {
|
|
// B12: the restored library image may no longer exist on disk
|
|
let imageUri: string | null = session.libraryImageUri || null;
|
|
if (imageUri) {
|
|
try {
|
|
const info = await FileSystem.getInfoAsync(imageUri);
|
|
if (!info.exists) imageUri = null;
|
|
} catch {
|
|
imageUri = null;
|
|
}
|
|
}
|
|
// A saved 'camera' mode is a deliberate choice and outranks a lingering
|
|
// library image: switching modes never clears libraryImageUri, so without
|
|
// this a session closed on the live camera came back up in the viewer.
|
|
if (imageUri && session.mode !== 'camera') {
|
|
setLibraryImageUri(imageUri);
|
|
setMode('library');
|
|
} else {
|
|
// No usable photo (or the user had chosen the camera): open the camera.
|
|
setMode('camera');
|
|
}
|
|
if (session.adjustments)
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
|
|
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
|
|
if (session.useGeotag !== undefined) setUseGeotag(pro && session.useGeotag);
|
|
if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
|
|
if (session.cropRatio) setCropRatio(session.cropRatio);
|
|
if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect });
|
|
// Committed crop: only trust a value that still matches the restored
|
|
// ratio, so a stale snapshot can never show a crop it never framed.
|
|
if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);
|
|
// NOTE: meterMode/shutterSound/rawEnabled are NOT restored from the
|
|
// session snapshot here — the session file is only written on background,
|
|
// so it can be stale after a force-kill. CAMERA_SETTINGS_KEY above
|
|
// persists them on every change and is authoritative for these three.
|
|
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),
|
|
color: typeof raw.color === 'string' ? raw.color : undefined,
|
|
size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined,
|
|
font: pro && typeof raw.font === 'string' ? raw.font : null,
|
|
});
|
|
}
|
|
if (session.gpsWm && typeof session.gpsWm === 'object') {
|
|
// Style chips only — the placement fractions are left alone (restoring
|
|
// a stale x/y would silently move the stamp the user just dragged).
|
|
const raw = session.gpsWm as Partial<GpsWatermark>;
|
|
const keep = (v: unknown, fb: boolean) => (typeof v === 'boolean' ? v : fb);
|
|
setGpsWm((g) => ({
|
|
...g,
|
|
color: typeof raw.color === 'string' ? raw.color : g.color,
|
|
showName: keep(raw.showName, true),
|
|
showTime: keep(raw.showTime, true),
|
|
}));
|
|
}
|
|
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.
|
|
const sim = FILM_SIMS.find((f) => f.id === session.selectedRecipeId);
|
|
restoredRecipe = sim
|
|
? filmSimToRecipe(sim)
|
|
: list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
|
|
}
|
|
}
|
|
|
|
if (restoredRecipe) {
|
|
// Recipe identity restored; the look (adjustments/frame/geotag/ratio)
|
|
// stays exactly as saved in the session above — applyRecipe would reset
|
|
// them to the recipe's canonical values and drop the user's tweaks.
|
|
setSelectedRecipe(restoredRecipe);
|
|
} else {
|
|
applyDefaultStartup();
|
|
}
|
|
};
|
|
|
|
const loadGPS = async () => {
|
|
const info = await getCurrentGPS();
|
|
if (info) {
|
|
setGpsInfo({ ...info, source: 'device' });
|
|
}
|
|
};
|
|
|
|
const applyRecipe = (recipe: Recipe) => {
|
|
commit(false, () => {
|
|
setSelectedRecipe(recipe);
|
|
// EVERY knob the recipe carries lands in the panel, backfilled over the
|
|
// defaults so a recipe saved before a knob existed cannot leave that knob
|
|
// on whatever the previous look happened to have.
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...recipe.adjustments });
|
|
setSelectedFrame(recipe.frameId || 'none');
|
|
});
|
|
// GPS watermark is the user's own setting (FRAME tab), not part of a
|
|
// preset look: recipes carry a useGeotag field but selecting one must
|
|
// never flip the watermark on/off.
|
|
};
|
|
|
|
// Picking a look — sim or saved recipe — loads the whole thing: identity,
|
|
// every parameter and the frame it was saved with. The panel then shows the
|
|
// values that are actually on screen (and stays editable from there). This
|
|
// used to keep the sliders for sims, which left the panel naming numbers the
|
|
// look no longer had: the sim carried its own adjustments and they were
|
|
// silently dropped. The session restore below is a different case — there the
|
|
// stored adjustments ARE the user's, so it still sets the recipe alone.
|
|
const handleSelectRecipe = (recipe: Recipe) => {
|
|
applyRecipe(recipe);
|
|
};
|
|
|
|
// The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe
|
|
// copy of it: every slider move rewrites its `adjustments` in place
|
|
// (handleUpdateAdjustments), so the canonical values only survive in the
|
|
// film-sim table and in the saved list.
|
|
const pristineRecipe = (recipe: Recipe): Recipe | null => {
|
|
const sim = FILM_SIMS.find((f) => f.id === recipe.id);
|
|
if (sim) return filmSimToRecipe(sim);
|
|
return recipes.find((r) => r.id === recipe.id) || null;
|
|
};
|
|
|
|
// Every edit belongs to the mode and the photo it was made on, so it never
|
|
// crosses into the next session: the sliders go back to the recipe's own
|
|
// values, a frame picked for that still is dropped, and the custom mark —
|
|
// placed against that very photo — is switched back off.
|
|
const resetEditsToRecipe = () => {
|
|
const base = selectedRecipe ? pristineRecipe(selectedRecipe) : null;
|
|
if (base) {
|
|
applyRecipe(base);
|
|
} else {
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS });
|
|
setSelectedFrame('none');
|
|
}
|
|
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
|
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
setGpsWm({ ...DEFAULT_GPS_WM });
|
|
// A look belongs to the photo and the mode it was edited on: the next load
|
|
// starts a fresh history, even when the values come back the same.
|
|
clearHistory();
|
|
};
|
|
|
|
// RESET (panel chip, any tab): the state a default start would leave — the
|
|
// startup look, every parameter back to neutral, frame off and both
|
|
// watermarks back to their defaults. Same pieces as resetEditsToRecipe, but
|
|
// the look goes back to the app's own default rather than the recipe's.
|
|
// A RESET on an already-clean look is not a step: BACK would have nothing
|
|
// to put back and would burn a press on a no-op.
|
|
const handleResetAll = () => {
|
|
if (resetDirty) commit(false, resetLookInPlace);
|
|
};
|
|
const resetLookInPlace = () => {
|
|
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
|
|
if (provia) {
|
|
applyRecipe(filmSimToRecipe(provia));
|
|
} else {
|
|
setSelectedRecipe(null);
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS });
|
|
setSelectedFrame('none');
|
|
}
|
|
setCustomWm({ ...DEFAULT_CUSTOM_WM });
|
|
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
|
|
setGpsWm({ ...DEFAULT_GPS_WM });
|
|
// The rest of the look's state, each back to the value a clean start ships
|
|
// with: the GPS stamp off, both watermark rotations upright, the library
|
|
// photo and its wall frame back to portrait. ponytail: mode + aspect stay
|
|
// put — they frame the shot you are working on, not the look.
|
|
setUseGeotag(false);
|
|
setWmRotation(0);
|
|
setPhotoRotation(0);
|
|
setPhotoStraighten(0);
|
|
setWallLandscape(false);
|
|
setCropRatio('none');
|
|
setCropApplied(null);
|
|
setCropRect(DEFAULT_CROP_RECT);
|
|
};
|
|
|
|
// RESET lights up while the look differs from what handleResetAll puts back —
|
|
// every clause here has a matching setter there, or the star never goes out.
|
|
// The clean-start values: the startup sim, neutral knobs, no frame, no crop,
|
|
// both marks at their defaults, GPS stamp off, nothing rotated.
|
|
const resetDirty =
|
|
(selectedRecipe?.id ?? 'sim-provia') !== 'sim-provia' ||
|
|
differs(adjustments, DEFAULT_ADJUSTMENTS) ||
|
|
selectedFrame !== 'none' ||
|
|
differs(customWm, DEFAULT_CUSTOM_WM) ||
|
|
differs(customWmApplied, DEFAULT_CUSTOM_WM) ||
|
|
differs(gpsWm, DEFAULT_GPS_WM) ||
|
|
useGeotag ||
|
|
wmRotation !== 0 ||
|
|
photoRotation !== 0 ||
|
|
photoStraighten !== 0 ||
|
|
wallLandscape ||
|
|
cropRatio !== 'none' ||
|
|
differs(cropRect, DEFAULT_CROP_RECT);
|
|
|
|
// ---- Look history: BACK (undo) + PEEK (hold to compare) ------------------
|
|
// Every edit the panel can make is a step: PRESETS/RECIPES, LIGHT, WB, FX and
|
|
// FRAME (frame, crop, turn, straighten, both watermarks, the GPS stamp). BACK
|
|
// pops one step; PEEK — a hold on the picture — shows the
|
|
// look one step back, with every tab's values travelling with it. Neither
|
|
// touches the stored values, the RECIPE or the export: undo writes them, peek
|
|
// only shows them. The history is seeded at every load, so BACK stops there.
|
|
type Look = {
|
|
recipe: Recipe | null;
|
|
adjustments: ColorAdjustments;
|
|
frame: FrameId;
|
|
cropRatio: CropRatio;
|
|
cropApplied: CropRatio | null;
|
|
cropRect: CropRect;
|
|
photoRotation: 0 | 90 | 180 | 270;
|
|
photoStraighten: number;
|
|
wallLandscape: boolean;
|
|
useGeotag: boolean;
|
|
wmRotation: number;
|
|
customWm: CustomWatermark;
|
|
customWmApplied: CustomWatermark;
|
|
gpsWm: GpsWatermark;
|
|
};
|
|
const lookNow = (): Look => ({
|
|
recipe: selectedRecipe,
|
|
adjustments,
|
|
frame: selectedFrame,
|
|
cropRatio,
|
|
cropApplied,
|
|
cropRect,
|
|
photoRotation,
|
|
photoStraighten,
|
|
wallLandscape,
|
|
useGeotag,
|
|
wmRotation,
|
|
customWm,
|
|
customWmApplied,
|
|
gpsWm,
|
|
});
|
|
const lookRef = useRef<Look | null>(null);
|
|
const pastRef = useRef<Look[]>([]);
|
|
const [undoDepth, setUndoDepth] = useState(0);
|
|
const [peekLook, setPeekLook] = useState<Look | null>(null);
|
|
// A gesture is one step: a slider drag (or a run of keystrokes) inside this
|
|
// gap stays the step it started as, so BACK undoes the whole gesture rather
|
|
// than one event of it.
|
|
const PEEK_GAP_MS = 700;
|
|
const lastStepRef = useRef(0);
|
|
// A step is pushed BEFORE the change lands. Nested commits (a handler whose
|
|
// work calls another committing one, e.g. RESET -> applyRecipe) belong to the
|
|
// outer step, or BACK would need one press per layer of one press.
|
|
const commitDepthRef = useRef(0);
|
|
const commit = (coalesce: boolean, mutate: () => void) => {
|
|
if (commitDepthRef.current > 0) {
|
|
mutate();
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (!coalesce || now - lastStepRef.current > PEEK_GAP_MS) {
|
|
if (lookRef.current) pastRef.current.push(lookRef.current);
|
|
setUndoDepth(pastRef.current.length);
|
|
}
|
|
lastStepRef.current = coalesce ? now : 0;
|
|
commitDepthRef.current++;
|
|
try {
|
|
mutate();
|
|
} finally {
|
|
commitDepthRef.current--;
|
|
}
|
|
};
|
|
// The loaded look is step zero — nothing before it can be undone.
|
|
const clearHistory = () => {
|
|
pastRef.current = [];
|
|
lastStepRef.current = 0;
|
|
setUndoDepth(0);
|
|
};
|
|
// FX's MONOCHROME switch: not a look of its own — it swaps the base filter for
|
|
// the mono stock and swaps right back. Adjustments are never touched, so a knob
|
|
// turned while it is on survives the round trip.
|
|
const monoOn = selectedRecipe?.baseFilter === 'monochrome';
|
|
const monoPrevRef = useRef<Recipe | null>(null);
|
|
|
|
const handleToggleMono = () => {
|
|
commit(true, () => {
|
|
if (selectedRecipe?.baseFilter === 'monochrome') {
|
|
const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!);
|
|
monoPrevRef.current = null;
|
|
setSelectedRecipe(back);
|
|
} else {
|
|
monoPrevRef.current = selectedRecipe;
|
|
setSelectedRecipe({
|
|
...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])),
|
|
id: 'mono',
|
|
name: 'MONOCHROME',
|
|
baseFilter: 'monochrome',
|
|
});
|
|
}
|
|
});
|
|
};
|
|
const applyLook = (l: Look) => {
|
|
setSelectedRecipe(l.recipe);
|
|
setAdjustments(l.adjustments);
|
|
setSelectedFrame(l.frame);
|
|
setCropRatio(l.cropRatio);
|
|
setCropApplied(l.cropApplied);
|
|
setCropRect(l.cropRect);
|
|
setPhotoRotation(l.photoRotation);
|
|
setPhotoStraighten(l.photoStraighten);
|
|
setWallLandscape(l.wallLandscape);
|
|
setUseGeotag(l.useGeotag);
|
|
setWmRotation(l.wmRotation);
|
|
setCustomWm(l.customWm);
|
|
setCustomWmApplied(l.customWmApplied);
|
|
setGpsWm(l.gpsWm);
|
|
};
|
|
const handleUndo = () => {
|
|
if (!pro) {
|
|
promptProFeature('Undo');
|
|
return;
|
|
}
|
|
const prev = pastRef.current.pop();
|
|
if (!prev) return;
|
|
setPeekLook(null);
|
|
lastStepRef.current = 0;
|
|
setUndoDepth(pastRef.current.length);
|
|
applyLook(prev);
|
|
};
|
|
const handlePeek = (on: boolean) => {
|
|
if (!on) {
|
|
setPeekLook(null);
|
|
return;
|
|
}
|
|
const before = pastRef.current[pastRef.current.length - 1];
|
|
setPeekLook(before ?? null);
|
|
};
|
|
// PEEK follows the finger on the picture (the chip is gone). Armed only when
|
|
// there is a step to compare against, so a long press to focus cannot turn
|
|
// into a paywall in a LITE build.
|
|
const peekArmed = pro && undoDepth > 0;
|
|
// The live look, mirrored every render so a handler can snapshot what was on
|
|
// screen just before it changes it.
|
|
const liveLook = lookNow();
|
|
lookRef.current = liveLook;
|
|
// The look the panel and the picture SHOW. While the finger is down on PEEK
|
|
// (or on the picture) every number and every tab travels back a step, so the
|
|
// before/after is readable, not just visible. The real values stay untouched:
|
|
// writes, RECIPE and the on-image drag baseline all read the live ones.
|
|
const shown = peekLook ?? liveLook;
|
|
const shownAdjustments = shown.adjustments;
|
|
const peeking = peekLook !== null;
|
|
// What the preview draws: the shown recipe's own chrome (base filter, name)
|
|
// rebuilt over the shown values, so a peek that steps over a PRESETS/RECIPES
|
|
// change brings that look's filter back with it. A camera start carries no
|
|
// recipe, so that stays the synthetic one.
|
|
const previewRecipe: Recipe = {
|
|
id: 'temp',
|
|
name: 'Custom',
|
|
baseFilter: 'none',
|
|
...(shown.recipe ?? {}),
|
|
adjustments: shown.adjustments,
|
|
frameId: shown.frame,
|
|
useGeotag: shown.useGeotag,
|
|
};
|
|
|
|
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
|
|
// Storm guard, generic across every param: same-value writes (slider
|
|
// micro-jitter / repeated pointer-move events) still build a fresh
|
|
// adjustments object and re-render the whole tree — skip them. Supersedes
|
|
// the old EV-only dedupe; EV flush at capture reads its own ref, so a
|
|
// skipped identical EV needs no re-send.
|
|
const keys = Object.keys(updates) as (keyof ColorAdjustments)[];
|
|
if (keys.every((k) => updates[k] === adjustments[k])) return;
|
|
// B8: never call a setState inside another setState's updater.
|
|
const next = { ...adjustments, ...updates };
|
|
// One drag = one step (see commit's coalescing): the step holds the values
|
|
// the drag started from, so BACK and PEEK land on the drag's own start.
|
|
// ponytail: a >700ms stall in the middle of one drag splits it in two.
|
|
commit(true, () => {
|
|
setAdjustments(next);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
adjustments: next,
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
// One band triple, as stored. A band back at [0,0,0] is DELETED, and the
|
|
// field goes back to `undefined` (not {}) when the last one leaves: the
|
|
// shallow differs() behind resetDirty then reads a clean mixer as clean.
|
|
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
|
|
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
|
const next: HslBand = [band[0], band[1], band[2]];
|
|
next[which] = Math.round(v);
|
|
const bands = { ...adjustments.hslBands };
|
|
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
|
|
else bands[hslBand] = next;
|
|
handleUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
|
|
};
|
|
|
|
// The mixer's three whole-image knobs: one door for all three, so the ruler
|
|
// and the field it writes are the same value wherever they were opened.
|
|
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
|
|
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
|
|
const clamped = Math.max(-10, Math.min(10, Math.round(v)));
|
|
handleUpdateAdjustments({ [key]: clamped } as Partial<ColorAdjustments>);
|
|
};
|
|
|
|
// The eyedropper's report: print the colour it read, point the ruler at that
|
|
// colour's band, hang the panel on the spot it came from, and put the tool
|
|
// away — one pick, one colour.
|
|
const pickColor = (
|
|
rgb: { r: number; g: number; b: number },
|
|
at: { fx: number; fy: number }
|
|
) => {
|
|
setHslSample(rgb);
|
|
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
|
|
setHslPickedAt(at);
|
|
setPicking(false);
|
|
};
|
|
|
|
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
|
|
const applyFrame = (frameId: FrameId) => {
|
|
setSelectedFrame(frameId);
|
|
// CROP is a plain-frame tool (polaroid/wall crop through their own window).
|
|
// Keep the crop out of a frame that ignores it, or the ratio survives in the
|
|
// strip and lights the CROP chip on a frame the export never crops.
|
|
if (frameId !== 'none' && frameId !== 'classic-white' && frameId !== 'cinematic') {
|
|
setCropRatio('none');
|
|
setCropApplied(null);
|
|
setCropRect(DEFAULT_CROP_RECT);
|
|
}
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
frameId,
|
|
});
|
|
}
|
|
};
|
|
|
|
// SETTINGS -> GPS. ON asks for the permission once and resolves a fix
|
|
// now, so the coordinates are already there when the shutter fires.
|
|
const handleToggleGps = async (enabled: boolean) => {
|
|
if (!enabled) {
|
|
setGpsEnabled(false);
|
|
// Only the device fix goes: a picked photo keeps its own EXIF location.
|
|
setGpsInfo((g) => (g && g.source === 'device' ? null : g));
|
|
return;
|
|
}
|
|
if (!(await requestLocationPermissions())) {
|
|
Alert.alert('Permission Denied', 'Enable location services to use GPS.');
|
|
return;
|
|
}
|
|
setGpsEnabled(true);
|
|
loadGPS();
|
|
};
|
|
|
|
const handleToggleGeotag = async (enabled: boolean) => {
|
|
if (enabled && !pro) {
|
|
promptProFeature('The GPS watermark');
|
|
return;
|
|
}
|
|
commit(false, () => {
|
|
setUseGeotag(enabled);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
useGeotag: enabled,
|
|
});
|
|
}
|
|
});
|
|
if (enabled && !gpsInfo) {
|
|
const locationGranted = await requestLocationPermissions();
|
|
if (locationGranted) {
|
|
setGpsEnabled(true);
|
|
loadGPS();
|
|
} else {
|
|
Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.');
|
|
setUseGeotag(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAspectRatio = (ratio: AspectRatio) => {
|
|
setAspectRatio(ratio);
|
|
};
|
|
|
|
const handleRawEnabled = (enabled: boolean) => {
|
|
rawFailRef.current = false; // allow one recovery alert per enable
|
|
setRawEnabled(enabled);
|
|
if (enabled) {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
}
|
|
};
|
|
|
|
const handleStartupMode = (mode: StartupMode) => {
|
|
setStartupMode(mode);
|
|
AsyncStorage.setItem(STARTUP_MODE_KEY, mode).catch(() => {});
|
|
};
|
|
|
|
// Persist device/camera settings on every change — not just on background —
|
|
// so they survive a kill/relaunch even under 'default' startup, which only
|
|
// resets the look (mode/ratio/recipe/geotag), not these.
|
|
useEffect(() => {
|
|
if (!settingsHydratedRef.current) return;
|
|
AsyncStorage.setItem(
|
|
CAMERA_SETTINGS_KEY,
|
|
JSON.stringify({ shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter })
|
|
).catch(() => {});
|
|
}, [shutterSound, flashMode, meterMode, rawEnabled, gpsEnabled, touchToShutter]);
|
|
|
|
// Latest-rawEnabled ref so the stable session-error callback below can read
|
|
// the live toggle without re-subscribing the Camera session on every render.
|
|
const rawEnabledRef = useRef(rawEnabled);
|
|
rawEnabledRef.current = rawEnabled;
|
|
// Attaching the DNG output while RAW is on adds a second ImageCapture use
|
|
// case — CameraX historically refuses two per camera. If the session errors
|
|
// in that state, drop RAW (removing the output reconfigures back to the
|
|
// single working JPEG session) instead of leaving a dead camera.
|
|
const handleCameraSessionError = useCallback((error: Error) => {
|
|
console.error('Camera session error:', error);
|
|
if (rawEnabledRef.current && !rawFailRef.current) {
|
|
rawFailRef.current = true;
|
|
setRawEnabled(false);
|
|
Alert.alert(
|
|
'RAW Unavailable',
|
|
'DNG capture could not start on this device, so RAW was turned off. JPEG capture keeps working.'
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
// Save the freshly captured DNG into an album literally named "RAW" (create
|
|
// the album on first use). Failure never blocks the JPEG pipeline.
|
|
const saveRawToAlbum = async (uri: string): Promise<boolean> => {
|
|
try {
|
|
const perm = await MediaLibraryLegacy.requestPermissionsAsync();
|
|
if (!perm.granted) {
|
|
console.warn('RAW save: media library permission denied');
|
|
return false;
|
|
}
|
|
const asset = await MediaLibraryLegacy.createAssetAsync(uri);
|
|
const albumName = 'RAW';
|
|
let album = await MediaLibraryLegacy.getAlbumAsync(albumName);
|
|
if (album) {
|
|
await MediaLibraryLegacy.addAssetsToAlbumAsync(asset, album);
|
|
} else {
|
|
await MediaLibraryLegacy.createAlbumAsync(albumName, asset);
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
console.error('RAW album save failed:', e);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleSaveRecipe = async (name: string) => {
|
|
if (liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
if (!selectedRecipe) return;
|
|
const newRecipe = await saveCustomRecipe({
|
|
name,
|
|
baseFilter: selectedRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
applyRecipe(newRecipe);
|
|
Alert.alert('Recipe Saved', `"${name}" has been created.`);
|
|
};
|
|
|
|
// TopBar SAVE & SHARE -> EXPORT & SHARE: the look on screen as an encrypted
|
|
// recipe file, handed to the share sheet (and to the phone's storage when
|
|
// the recipient sheet is used to save it).
|
|
const handleExportShare = async (name: string) => {
|
|
try {
|
|
const { exportRecipeXml } = await import('./src/utils/recipeShare');
|
|
const xml = exportRecipeXml({
|
|
id: 'share',
|
|
name,
|
|
baseFilter: activeRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const file = `${FileSystem.cacheDirectory}${name.replace(/[^\w.-]+/g, '_') || 'recipe'}.rcx.xml`;
|
|
await FileSystem.writeAsStringAsync(file, xml);
|
|
if (!(await Sharing.isAvailableAsync())) {
|
|
Alert.alert('Recipe Exported', `Saved as ${file}`);
|
|
return;
|
|
}
|
|
await Sharing.shareAsync(file, { mimeType: 'application/xml', dialogTitle: `Share recipe "${name}"` });
|
|
} catch (e) {
|
|
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// PRESETS -> IMPORT / SCAN QR: a `.recipe` file that arrived from elsewhere —
|
|
// picked off storage or fetched from a scanned link — becomes a normal custom
|
|
// recipe, and the look on screen switches to it.
|
|
const storeImportedRecipe = async (xml: string) => {
|
|
const { importRecipeXml } = await import('./src/utils/recipeShare');
|
|
const created = await saveCustomRecipe(importRecipeXml(xml));
|
|
setRecipes(await getAllRecipes());
|
|
applyRecipe(created);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert('Recipe Imported', `"${created.name}" has been added.`);
|
|
};
|
|
|
|
// PRESETS -> IMPORT: a recipe file shared from another phone, picked from
|
|
// storage, decoded and stored as a normal custom recipe.
|
|
const handleImportRecipe = async () => {
|
|
if (liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
try {
|
|
const picked = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
|
if (picked.canceled) return;
|
|
await storeImportedRecipe(await FileSystem.readAsStringAsync(picked.assets[0].uri));
|
|
} catch (e) {
|
|
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// PRESETS -> SCAN QR: the landing page's recipe card carries a link to a
|
|
// photo's own `.recipe` file, so a scan is the same import with the file
|
|
// fetched instead of picked. The code holds a URL, never the recipe, so
|
|
// anything that is not the preset route — a shop link, a wifi code — is
|
|
// refused with the reason. The sheet closes either way; a failure is a
|
|
// dialog, not a scanner left running.
|
|
const handleScanRecipe = async (value: string) => {
|
|
setScanVisible(false);
|
|
if (liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
try {
|
|
const link = /^https?:\/\/\S+\/api\/photos\/\d+\/preset\.recipe$/.exec(value.trim());
|
|
if (!link) throw new Error('That code is not a RecipesCam recipe link.');
|
|
const res = await fetch(link[0]);
|
|
if (!res.ok) throw new Error(`The recipe link answered ${res.status}.`);
|
|
await storeImportedRecipe(await res.text());
|
|
} catch (e) {
|
|
Alert.alert('Import failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
};
|
|
|
|
// TopBar "SAVE" on a user-created recipe: overwrite it in place, same id.
|
|
const handleOverwriteRecipe = async (name: string) => {
|
|
if (!selectedRecipe) return;
|
|
await updateCustomRecipe(selectedRecipe.id, {
|
|
name,
|
|
baseFilter: selectedRecipe.baseFilter,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
});
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
const updated = list.find((r) => r.id === selectedRecipe.id);
|
|
if (updated) applyRecipe(updated);
|
|
Alert.alert('Recipe Updated', `"${name}" has been saved.`);
|
|
};
|
|
|
|
// The recipe editor (the CREATE form in edit mode) SAVE. The typed name is
|
|
// the switch: unchanged = overwrite that recipe in place (same id, new
|
|
// parameters), new = keep the original and store the edit as its own recipe.
|
|
// The editor only closes first, the confirm dialog comes right after.
|
|
const handleUpdateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
|
|
const target = editingRecipe;
|
|
setEditingRecipe(null);
|
|
if (!target) return;
|
|
const name = recipe.name.trim();
|
|
const unchanged = name === target.name.trim();
|
|
if (!unchanged && liteSaveBlocked) {
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
const commit = async () => {
|
|
if (unchanged) {
|
|
await updateCustomRecipe(target.id, { ...recipe, name });
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
const updated = list.find((r) => r.id === target.id);
|
|
if (updated) applyRecipe(updated);
|
|
} else {
|
|
const created = await saveCustomRecipe({ ...recipe, name });
|
|
setRecipes(await getAllRecipes());
|
|
applyRecipe(created);
|
|
}
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert(
|
|
unchanged ? 'Recipe Updated' : 'Recipe Saved',
|
|
`"${name}" has been saved.`
|
|
);
|
|
};
|
|
Alert.alert(
|
|
unchanged ? 'Overwrite recipe?' : 'Save as new recipe?',
|
|
unchanged
|
|
? `Replace "${target.name}" with the edited parameters?`
|
|
: `Keep "${target.name}" and save the edit as "${name}"?`,
|
|
[
|
|
{ text: 'CANCEL', style: 'cancel' },
|
|
{ text: unchanged ? 'OVERWRITE' : 'SAVE', onPress: () => { commit(); } },
|
|
],
|
|
);
|
|
};
|
|
|
|
// PRESETS → CREATE quick-input: build a brand-new recipe from the modal's
|
|
// typed fields, persist it, and apply it (the user sees the look immediately).
|
|
const handleCreateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
|
|
// Lite composes the recipe and applies it as a throwaway draft so the look
|
|
// can be tried; storing it is what the LITE_RECIPE_LIMIT caps. 'draft' is no
|
|
// free id, so the shutter gate already treats the look as the PRO preset.
|
|
if (liteSaveBlocked) {
|
|
applyRecipe({ ...recipe, id: 'draft', isCustom: false });
|
|
promptRecipeLimit();
|
|
return;
|
|
}
|
|
const newRecipe = await saveCustomRecipe(recipe);
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
applyRecipe(newRecipe);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
};
|
|
|
|
// Deleting is PRO: Lite may create and edit its recipes, it may not drop them.
|
|
const handleDeleteRecipe = async (id: string) => {
|
|
if (!pro) {
|
|
promptProFeature('Deleting recipes');
|
|
return;
|
|
}
|
|
await deleteCustomRecipe(id);
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
if (selectedRecipe?.id === id && list.length > 0) {
|
|
applyRecipe(list[0]);
|
|
}
|
|
};
|
|
|
|
// Star/un-star a recipe: optimistic (the chip repaints on tap), persisted in
|
|
// the background. The write is the only failure worth logging — the look is
|
|
// untouched either way.
|
|
const handleToggleFavorite = (id: string) => {
|
|
const on = !favoriteIds.includes(id);
|
|
// LITE gets one favorite; PRO is what buys a list. Un-starring is never
|
|
// gated, so the way back under the limit is always open.
|
|
if (on && !pro && favoriteIds.length >= LITE_FAVORITE_LIMIT) {
|
|
promptProFeature(`Favoriting more than ${LITE_FAVORITE_LIMIT} recipe`);
|
|
return;
|
|
}
|
|
setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id)));
|
|
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
|
|
};
|
|
|
|
// FRAME-tab AUTO: the photo is levelled from its OWN pixels, not from the
|
|
// phone. A library still carries no record of how the camera was held when
|
|
// it was shot (EXIF Orientation only knows 0/90/180/270), so the sensor had
|
|
// nothing to offer; the dominant line in the picture is the only horizon
|
|
// there is. A one-shot: it replaces whatever the slider or a previous AUTO
|
|
// left, and does nothing at all when the picture has no line worth trusting.
|
|
const handleAutoStraighten = useCallback(() => {
|
|
const tilt = viewfinderRef.current?.detectHorizon?.() ?? null;
|
|
if (tilt == null) return;
|
|
commit(false, () => setPhotoStraighten(-tilt));
|
|
// commit only reads refs, so the empty dep list stays honest.
|
|
}, []);
|
|
|
|
// OPEN IMAGE: the in-app picker (LibraryPickerModal) does the choosing — a
|
|
// grid, then the photo full screen to pinch/double-tap zoom and swipe through,
|
|
// then USE or CANCEL. The system picker it replaced returned the still on the
|
|
// first tap (no preview) and, on Android 13+, as a redacted cache copy with
|
|
// assetId=null — the grid uri keeps the MediaStore asset id, so with
|
|
// ACCESS_MEDIA_LOCATION granted the file is not redacted and tier-1 EXIF /
|
|
// tier-2 MediaStore both still work.
|
|
const handlePickImage = () => {
|
|
if (isProcessing) return;
|
|
setLibraryPickerVisible(true);
|
|
};
|
|
|
|
const handleLibraryPicked = async (asset: PickedPhotoAsset) => {
|
|
setLibraryPickerVisible(false);
|
|
if (!asset.uri) return;
|
|
setLibraryImageUri(asset.uri);
|
|
// A new photo starts level: the previous photo's turn and fine
|
|
// straighten must not carry over.
|
|
setPhotoRotation(0);
|
|
setPhotoStraighten(0);
|
|
setMode('library');
|
|
// Picking from the camera is the other half of that switch (the camera
|
|
// button is the way back), so the camera's edits must not land on the
|
|
// photo: the picked still starts at the recipe's own values too.
|
|
if (mode !== 'library') resetEditsToRecipe();
|
|
// Clear old GPS — will be replaced if the photo (or device) has location data
|
|
setGpsInfo(null);
|
|
|
|
// Multi-tier GPS resolution: EXIF → MediaLibrary. Never the device's own
|
|
// location — that is where the phone is now, not where the photo was
|
|
// taken, and stamping it just printed the wrong place name on the mark.
|
|
const { resolvePhotoGPS } = await import('./src/utils/photoGps');
|
|
const { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled });
|
|
setGpsInfo(gps);
|
|
if (permissionDenied) {
|
|
console.warn('Media library read permission denied — photo GPS unavailable.');
|
|
}
|
|
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
};
|
|
|
|
// System share intake. A shared photo arrives as the launch intent's
|
|
// EXTRA_STREAM, which only the Kotlin module can read (takeSharedImage): it
|
|
// copies the stream into the cache and this feeds it to the library pipeline
|
|
// as an ordinary photo — same path as a pick, GPS and all.
|
|
const sharedPickedRef = useRef(handleLibraryPicked);
|
|
sharedPickedRef.current = handleLibraryPicked;
|
|
useEffect(() => {
|
|
let stale = false;
|
|
let busy = false;
|
|
const importShared = async () => {
|
|
if (stale || busy) return;
|
|
busy = true;
|
|
try {
|
|
await sessionInitRef.current;
|
|
if (stale) return;
|
|
const shared = await RecipescamExport.takeSharedImage();
|
|
if (stale || !shared?.path) return;
|
|
sharedPickedRef.current({ uri: `file://${shared.path}`, assetId: shared.assetId || null });
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
};
|
|
importShared();
|
|
// The other half is a share landing on an app that is already alive: no
|
|
// mount pass sees that intent, only this background -> active transition.
|
|
// The native read clears the extra, so later resumes import nothing.
|
|
const sub = AppState.addEventListener('change', (nextAppState) => {
|
|
if (nextAppState === 'active') importShared();
|
|
});
|
|
return () => {
|
|
stale = true;
|
|
sub.remove();
|
|
};
|
|
}, []);
|
|
|
|
const activeRecipe: Recipe =
|
|
selectedRecipe || {
|
|
id: 'temp',
|
|
name: 'Custom',
|
|
baseFilter: 'none',
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
};
|
|
|
|
// Thumbnail/preview target for the camera-mode right button: newest photo.
|
|
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
|
|
|
|
// The PRO feature the current look is using, or null while it is Lite-clean.
|
|
// Only the EXPORT consults it: the preview shows every look for free, so the
|
|
// answer is computed where the file is about to be written, not on the chips.
|
|
// PRO unlocks every look, so a paying user has nothing left in use: only a
|
|
// Lite session can trip them: a printed frame it previewed, or a GPS stamp
|
|
// that arrived with a restored session.
|
|
const proLook = pro ? null : proLookInUse({ frame: selectedFrame, gpsWm: useGeotag, hdf: adjustments.hdf ?? 0 });
|
|
// The native Kotlin exporter has no text-watermark draw, so a Lite export
|
|
// (which always carries the mark) must take the Skia engine.
|
|
const nativeExport = NATIVE_EXPORT && pro;
|
|
// Shutter/export with a PRO look: nothing is rendered — name the feature so
|
|
// the user knows which PRO build would render it.
|
|
const promptPro = (feature: string) => {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
Alert.alert(
|
|
'PRO LOOK',
|
|
`${feature} can be previewed and edited, but exporting it needs PRO.`,
|
|
[
|
|
{ text: 'NOT NOW', style: 'cancel' },
|
|
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
|
|
]
|
|
);
|
|
};
|
|
// Saving, starring, spawning: the action IS the feature, so there is no look
|
|
// to preview first — a LITE tap names it and nothing else happens.
|
|
const promptProFeature = (feature: string) => {
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
Alert.alert('PRO FEATURE', `${feature} needs PRO.`, [
|
|
{ text: 'NOT NOW', style: 'cancel' },
|
|
{ text: 'UNLOCK PRO', onPress: () => setShowPro(true) },
|
|
]);
|
|
};
|
|
|
|
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
|
|
// (not the live draft), 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;
|
|
color?: string;
|
|
size?: number;
|
|
font?: string | null;
|
|
} | null =>
|
|
// The user's own mark is free in both tiers. LITE only narrows the FACE it
|
|
// may use (one basic font), not the mark itself.
|
|
customWmApplied.enabled && customWmApplied.text.trim().length > 0
|
|
? {
|
|
text: customWmApplied.text.trim(),
|
|
x: customWmApplied.x,
|
|
y: customWmApplied.y,
|
|
color: customWmApplied.color,
|
|
size: customWmApplied.size,
|
|
font: pro ? customWmApplied.font : null,
|
|
}
|
|
: null;
|
|
|
|
// The free build's own mark, burned in on top of the custom one.
|
|
const liteMark = pro ? null : LITE_MARK;
|
|
|
|
// `burst` marks one frame of a held-shutter burst: the loop already gave the
|
|
// single haptic and the click would machine-gun, so both are muted, and RAW
|
|
// is skipped (a DNG per frame doubles the exposures the burst has to fit).
|
|
const handleCapture = async (opts?: { burst?: boolean }) => {
|
|
if (isProcessing) return;
|
|
|
|
// A PRO look can be composed but not rendered: stop before the shutter, so
|
|
// no capture, no queue entry and no cache file is produced.
|
|
if (proLook) {
|
|
promptPro(proLook);
|
|
return;
|
|
}
|
|
|
|
// Camera mode (Phase 4, option A): real capture through vision-camera.
|
|
if (mode === 'camera') {
|
|
if (!cameraPermissionGranted) {
|
|
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
|
|
return;
|
|
}
|
|
if (!cameraReadyRef.current) {
|
|
pendingShotRef.current = true;
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
return;
|
|
}
|
|
pendingShotRef.current = false;
|
|
if (!photoOutput) {
|
|
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
|
|
return;
|
|
}
|
|
// One capture in flight at a time — the shutter button, a viewfinder tap
|
|
// and a burst frame all land here.
|
|
if (captureBusyRef.current) return;
|
|
captureBusyRef.current = true;
|
|
if (!opts?.burst) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
try {
|
|
// A photo taken now is at the device's current spot — resolve device GPS
|
|
// fresh instead of reusing GPS left over from a previously loaded photo.
|
|
let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null;
|
|
// Kick the fix off here but do NOT await it before the shutter: this is
|
|
// a real GPS read plus a network reverse-geocode, and the sensor sat
|
|
// idle for as long as it took. Resolved below, before the export queue.
|
|
// Only the GPS MARK needs a fresh fix on the capture path: without it
|
|
// the shutter must not wait for the receiver (two seconds of a
|
|
// stamp-less photo is worse than none). EXIF still gets whatever fix
|
|
// the launch prewarm already resolved.
|
|
const gpsPending = gpsEnabled && useGeotag && !captureGps ? getCurrentGPS() : null;
|
|
|
|
const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name
|
|
// 0.5x is a native Camera2 session on the hidden ultra-wide lens, not a
|
|
// CameraX zoom ratio: no CameraX session is bound while it is live, so
|
|
// the still has to come from the native module — and Camera2 fires no
|
|
// shutter sound of its own. The LED is a CameraX capture setting, so the
|
|
// native module re-applies the viewfinder's flashMode on its own still
|
|
// request, and drops it back to 'off' on a lens with no flash unit.
|
|
const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true;
|
|
let filePath: string;
|
|
if (ultraWideShot) {
|
|
filePath = await UltraWide.captureAsync(flashMode);
|
|
} else {
|
|
filePath = (
|
|
await photoOutput.capturePhotoToFile(
|
|
{
|
|
enableShutterSound: shutterSound === 'on' && !opts?.burst,
|
|
// Capture-time LED flash. Forced back to 'off' on a lens with no
|
|
// flash unit: the spec throws for anything else.
|
|
flashMode: flashAvailable ? flashMode : 'off',
|
|
},
|
|
// The Nitro spec takes the callback bag as a required second
|
|
// argument (every field in it optional) — one argument throws.
|
|
{},
|
|
)
|
|
).filePath;
|
|
}
|
|
// Low light, hand-held: one frame is mostly sensor noise, and no OEM stack is
|
|
// reachable from here (the extension probe found the vendor pipelines closed to a
|
|
// third-party client). So take the frames and average them ourselves. Four ways
|
|
// out, checked before an extra exposure is spent: the LED is the light source
|
|
// (a burst would ride it while it recharges), the ultra-wide still came from the
|
|
// native Camera2 session, a held burst is the user asking for ONE frame, and RAW
|
|
// needs the second ImageCapture output CameraX will not bind alongside this one.
|
|
// canBurst: on a 1 GB floor device (deviceFloor.ts) three raws plus the
|
|
// alignment rasters are seconds of shutter lag, so the plain frame stands.
|
|
// Set when the low-light burst below starts its merge. The merge is not
|
|
// awaited on the shutter path: the export task awaits it instead, so the
|
|
// next shot is accepted while the frames are still being averaged.
|
|
let burstMerge: Promise<string | null> | null = null;
|
|
if (!ultraWideShot && !opts?.burst && !rawEnabled && !(flashAvailable && flashMode !== 'off') && canBurst(devicePerf().ramMb)) {
|
|
try {
|
|
// ISO is the only signal this app can read for ambient light: the controller
|
|
// reports 0.0 for every still, so it comes off the file the shutter just
|
|
// wrote. No tag, no burst.
|
|
const jpeg = await readPhotoBytes(toUri(filePath));
|
|
const iso = jpeg ? readJpegIso(jpeg) : null;
|
|
if (iso !== null && iso >= BURST_MIN_ISO) {
|
|
const frames = [filePath];
|
|
for (let i = 1; i < BURST_FRAMES; i++) {
|
|
const extra = await photoOutput.capturePhotoToFile(
|
|
// No second shutter sound and no flash: three clicks for one photo
|
|
// reads as a malfunction.
|
|
{ enableShutterSound: false, flashMode: 'off' },
|
|
{},
|
|
);
|
|
frames.push(extra.filePath);
|
|
}
|
|
// Averaging three 12 MP frames costs ~1.4 s measured on the 12S Ultra
|
|
// (three decodes, two alignment stages, one draw pass, one JPEG
|
|
// encode) and all three frames are already in hand, so the merge
|
|
// rides the FIFO export queue below instead of the shutter path:
|
|
// the next shot is accepted as soon as the exposures land, and only
|
|
// the export of this photo waits on the average. A refused merge
|
|
// (null: drift too large, a frame unreadable) keeps the shutter frame.
|
|
// ponytail: a merge in the queue and a following capture can each
|
|
// hold a 12 MP frame (~48 MB); the FIFO serializes the merge against
|
|
// the render, not against the camera. Re-serialize if a low-RAM body
|
|
// starts swapping.
|
|
burstMerge = mergeBurst(frames);
|
|
}
|
|
} catch (burstError) {
|
|
// A failed burst costs two wasted exposures, never the photo.
|
|
console.warn('Low-light burst skipped:', burstError);
|
|
}
|
|
}
|
|
|
|
const sourceUri = toUri(filePath);
|
|
|
|
// RAW DNG sidecar: fire the second exposure immediately after the JPEG
|
|
// so the two shots stay a few tens of ms apart, then file it into the
|
|
// "RAW" album without blocking the JPEG export below.
|
|
if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) {
|
|
try {
|
|
const rawFile = await rawPhotoOutput.capturePhotoToFile(
|
|
{
|
|
enableShutterSound: shutterSound === 'on',
|
|
// Same light as the JPEG it follows: the flash unit may still be
|
|
// recharging tens of ms later, in which case the DNG just lands
|
|
// darker than its JPEG — a silent raw, never a broken capture.
|
|
flashMode: flashAvailable ? flashMode : 'off',
|
|
},
|
|
{},
|
|
);
|
|
const rawUri = toUri(rawFile.filePath);
|
|
saveRawToAlbum(rawUri).then((ok) => {
|
|
if (!ok) {
|
|
Alert.alert('RAW Not Saved', 'The DNG was captured but could not be added to the RAW album.');
|
|
}
|
|
});
|
|
} catch (rawError) {
|
|
console.error('RAW capture failed:', rawError);
|
|
}
|
|
}
|
|
|
|
// Snapshot the look at shutter time, then render OFF the shutter path:
|
|
// processAndExportPhoto (~1s Skia render + gallery save) is enqueued on
|
|
// the FIFO background queue — the shutter returns in tens of ms and the
|
|
// next shot can be taken immediately; no full-screen RENDERING overlay.
|
|
// Finished photos land in history (and the gallery) when their turn
|
|
// completes; the thumbnail button shows each as it arrives.
|
|
// P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport —
|
|
// same queue/pushPhoto/Haptics/Alert behavior, different renderer.
|
|
if (gpsPending) {
|
|
const deviceGps = await withDeadline(gpsPending, GPS_WAIT_MS);
|
|
if (deviceGps) {
|
|
captureGps = { ...deviceGps, source: 'device' as const };
|
|
setGpsInfo(captureGps);
|
|
}
|
|
}
|
|
|
|
const recipeName = activeRecipe.name;
|
|
// Read at shutter time, not render time — the queued render runs after
|
|
// the next shot and the gesture may have moved on.
|
|
const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null;
|
|
const render = async () => {
|
|
const recipeArgs = {
|
|
...activeRecipe,
|
|
adjustments: renderAdj,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
};
|
|
const options = {
|
|
// Live capture: gallery name RC_<yyyymmdd_hhmmss>.jpg and the
|
|
// shutter instant as the EXIF capture date.
|
|
exported: false,
|
|
capturedAt,
|
|
// 300dpi header. No evFromCamera: the camera AE bias is never written,
|
|
// so the export applies the same software EV gain the preview shows.
|
|
// No forced output sharpen either: the 0.5 unsharp mask multiplied
|
|
// sensor noise ~3.2x (5x at Nyquist), so dim/high-ISO captures came
|
|
// back far noisier than the preview. The SHARPENING knob still
|
|
// sharpens when the user dials it above 0.
|
|
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
|
|
dpi: 300,
|
|
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
|
|
watermark: wmForExport(),
|
|
liteMark,
|
|
watermarkRotation: wmRotation,
|
|
gpsWatermark: gpsWm,
|
|
wallframeLandscape: wallLandscape,
|
|
// FRAME-tab pinch/drag inside a polaroid card window or wall-frame
|
|
// opening: save the same crop the preview showed (null = 1x).
|
|
frameWindowZoom: frameZoom,
|
|
};
|
|
const paint = await loadRenderer(nativeExport);
|
|
// The burst average lands here, in the queue: the export waits for it,
|
|
// the shutter did not. sourceUri is the frame the shutter already wrote.
|
|
const source = burstMerge ? toUri((await burstMerge) ?? filePath) : sourceUri;
|
|
return paint(source, recipeArgs, selectedFrame, useGeotag, captureGps, options);
|
|
};
|
|
enqueueExport(async () => {
|
|
try {
|
|
const result = await render();
|
|
if (result) {
|
|
// No auto preview after a capture — the thumbnail button opens
|
|
// the swipe viewer. Saved to session history either way.
|
|
pushPhoto({
|
|
uri: result.uri,
|
|
recipeName,
|
|
savedToLibrary: result.savedToLibrary,
|
|
assetId: result.assetId,
|
|
});
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
} else {
|
|
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Camera export failed:', error);
|
|
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Camera capture failed:', error);
|
|
Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.');
|
|
} finally {
|
|
captureBusyRef.current = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Library mode capture/export: processes selected image with recipe, overlays, text
|
|
if (!libraryImageUri || !selectedRecipe) {
|
|
Alert.alert('Error', 'Please select an image from the library first.');
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
|
|
// Plain library frames export the photo at its own aspect (the ratio band is
|
|
// a camera-composition tool); polaroid/wall keep their framed ratio crop.
|
|
const libraryPlainFrame =
|
|
selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic';
|
|
const libraryAspect = aspectRatio === 'full' || libraryPlainFrame ? undefined : aspectRatio;
|
|
// FRAME-tab CROP: only the plain frames (polaroid/wall crop through their
|
|
// own window). The native exporter knows nothing about it, so a cropped
|
|
// export always takes the Skia path.
|
|
// The fixed ratios take their rectangle from the viewfinder: the band plus
|
|
// whatever pinch/drag the user applied inside it. cropWH stays as the
|
|
// fallback for the (impossible in practice) case of an unloaded photo.
|
|
const ratioCrop =
|
|
cropRatio !== 'none' && cropRatio !== 'free'
|
|
? viewfinderRef.current?.getCropRatioRect?.() ?? null
|
|
: null;
|
|
const cropOptions: { cropWH?: number; cropRect?: CropRect } =
|
|
!libraryPlainFrame || cropRatio === 'none'
|
|
? {}
|
|
: cropRatio === 'free'
|
|
? { cropRect }
|
|
: ratioCrop
|
|
? { cropRect: ratioCrop }
|
|
: { cropWH: CROP_W_H[cropRatio] };
|
|
|
|
const paint = await loadRenderer(nativeExport && !cropOptions.cropRect);
|
|
const result = nativeExport && !cropOptions.cropRect
|
|
? await paint(
|
|
libraryImageUri,
|
|
{
|
|
...selectedRecipe,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
},
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
// Library export keeps the on-screen composition: plain frames export
|
|
// the photo as seen (own aspect), polaroid/wall keep their ratio crop.
|
|
{
|
|
aspect: libraryAspect,
|
|
// Library export: RC_<ts>_exported.jpg, and the loaded photo's
|
|
// EXIF (camera, dates, GPS) is carried into the file.
|
|
exported: true,
|
|
watermark: wmForExport(),
|
|
liteMark,
|
|
watermarkRotation: wmRotation,
|
|
gpsWatermark: gpsWm,
|
|
wallframeLandscape: wallLandscape,
|
|
}
|
|
)
|
|
: await paint(
|
|
libraryImageUri,
|
|
{
|
|
...selectedRecipe,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
},
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
// Library export keeps the on-screen composition: plain frames export
|
|
// the photo as seen (own aspect), polaroid/wall keep their ratio crop.
|
|
{
|
|
aspect: libraryAspect,
|
|
// Library export: RC_<ts>_exported.jpg, and the loaded photo's
|
|
// EXIF (camera, dates, GPS) is carried into the file.
|
|
exported: true,
|
|
watermark: wmForExport(),
|
|
liteMark,
|
|
watermarkRotation: wmRotation,
|
|
gpsWatermark: gpsWm,
|
|
wallframeLandscape: wallLandscape,
|
|
// FRAME-tab CROP: the exact keep-rectangle the preview showed.
|
|
...cropOptions,
|
|
// 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,
|
|
photoRotation,
|
|
photoStraighten,
|
|
}
|
|
);
|
|
|
|
setIsProcessing(false);
|
|
|
|
if (result) {
|
|
pushPhoto({
|
|
uri: result.uri,
|
|
recipeName: activeRecipe.name,
|
|
savedToLibrary: result.savedToLibrary,
|
|
assetId: result.assetId,
|
|
});
|
|
// Library has no thumbnail button (right button switches back to camera),
|
|
// so the export result auto-opens the viewer on the newest photo.
|
|
setViewerIndex(photoHistory.length);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
} else {
|
|
Alert.alert('Failed', 'Error occurred while processing image.');
|
|
}
|
|
};
|
|
|
|
// Volume keys as a shutter release. `handleCapture` is rebuilt every render,
|
|
// so the listener is bound once and reaches the current one through a ref.
|
|
const captureFromKeyRef = useRef(handleCapture);
|
|
captureFromKeyRef.current = handleCapture;
|
|
// Session up -> replay a queued press. Stable identity: the Viewfinder's
|
|
// onStarted/onStopped callbacks are memoised around it.
|
|
const handleCameraReady = useCallback((ready: boolean) => {
|
|
cameraReadyRef.current = ready;
|
|
if (ready && pendingShotRef.current) {
|
|
pendingShotRef.current = false;
|
|
setTimeout(() => void captureFromKeyRef.current(), 0);
|
|
}
|
|
}, []);
|
|
useEffect(() => {
|
|
const sub = VolumeShutter.addListener('onShutter', () => {
|
|
void captureFromKeyRef.current();
|
|
});
|
|
return () => sub.remove();
|
|
}, []);
|
|
// Only the camera screen claims the keys; everywhere else (the library)
|
|
// they stay volume keys. The modals need no flag of their own: an RN
|
|
// Modal is a window of its own and holds the focus, so the activity never
|
|
// sees a key while one is up.
|
|
useEffect(() => {
|
|
VolumeShutter.setEnabled(mode === 'camera');
|
|
}, [mode]);
|
|
|
|
// Held-shutter burst: press and hold the shutter, frames keep coming until
|
|
// the finger lifts. Every frame is a normal capture (GPS resolve, RAW rules,
|
|
// background render enqueue), so a burst photo is indistinguishable from a
|
|
// tapped one apart from the muted haptic/sound. The `await` IS the pacing: a
|
|
// frame cannot start before the previous one is on disk, which is why no
|
|
// timer is needed (and why 'speed' buys nothing extra here).
|
|
// ponytail: BURST_MAX is a runaway guard for a lost press-out (responder
|
|
// terminated mid-hold); raise it if bursts longer than 30 frames are wanted.
|
|
const BURST_MAX = 30;
|
|
const burstRef = useRef(false);
|
|
const handleBurstStart = () => {
|
|
if (mode !== 'camera' || burstRef.current) return;
|
|
if (proLook) {
|
|
promptPro(proLook);
|
|
return;
|
|
}
|
|
burstRef.current = true;
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
void (async () => {
|
|
for (let i = 0; i < BURST_MAX && burstRef.current; i++) {
|
|
await handleCapture({ burst: true });
|
|
}
|
|
burstRef.current = false;
|
|
})();
|
|
};
|
|
const handleBurstStop = () => {
|
|
burstRef.current = false;
|
|
};
|
|
|
|
// Switching back to camera: drop GPS that belongs to a library photo, so the
|
|
// live view (and any capture) never stamps a stale location.
|
|
const handleSetMode = (next: 'camera' | 'library') => {
|
|
if (next === mode) return;
|
|
if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
|
|
setGpsInfo(null);
|
|
}
|
|
// The deck panels belong to one mode only (FRAME exists in library alone,
|
|
// and the WATERMARK panel is the watermark's edit session), so every switch
|
|
// closes them: no frame chip row over the live preview, and no watermark
|
|
// placement layer left eating the camera's tap-to-focus.
|
|
setOpenTab(null);
|
|
setOpenParamKey(null);
|
|
resetEditsToRecipe();
|
|
// Camera mode has no FRAME picker to undo a frame, so even a recipe that
|
|
// carries one must not follow the user onto the live camera (and into the
|
|
// capture, which burns the frame in).
|
|
if (next === 'camera') setSelectedFrame('none');
|
|
setMode(next);
|
|
};
|
|
|
|
// Append a finished export (camera capture or library export) to the session
|
|
// photo history. Returned entry id lets callers delete by id.
|
|
const pushPhoto = (p: Omit<PhotoEntry, 'id'>): string => {
|
|
const id = `photo-${Date.now()}-${photoSeq.current++}`;
|
|
setPhotoHistory((h) => [...h, { id, ...p }]);
|
|
return id;
|
|
};
|
|
|
|
// Thumbnail (camera mode) opens the viewer on the newest photo.
|
|
const openLatestViewer = () => {
|
|
if (photoHistory.length > 0) setViewerIndex(photoHistory.length - 1);
|
|
};
|
|
|
|
// Viewer trash: delete the photo from the app AND its gallery copy (when it
|
|
// was saved). Deleting a saved photo goes through Android's per-item system
|
|
// confirmation (scoped storage — every single delete asks again by design);
|
|
// if the user cancels THAT dialog the photo is kept in both places.
|
|
const handleDeletePhoto = async (id: string) => {
|
|
const idx = photoHistory.findIndex((p) => p.id === id);
|
|
if (idx < 0) return;
|
|
const entry = photoHistory[idx];
|
|
const remove = () => {
|
|
const next = photoHistory.filter((p) => p.id !== id);
|
|
setPhotoHistory(next);
|
|
setViewerIndex((prev) => {
|
|
if (prev === null) return null;
|
|
if (next.length === 0) return null;
|
|
if (prev > idx) return prev - 1;
|
|
if (prev >= next.length) return next.length - 1;
|
|
return prev;
|
|
});
|
|
};
|
|
const deleteCache = async () => {
|
|
try {
|
|
await FileSystem.deleteAsync(entry.uri, { idempotent: true });
|
|
} catch (e) {
|
|
console.warn('Photo cache delete failed:', e);
|
|
}
|
|
};
|
|
|
|
if (!entry.savedToLibrary || !entry.assetId) {
|
|
// Never reached the gallery — nothing for the OS to confirm.
|
|
remove();
|
|
deleteCache();
|
|
return;
|
|
}
|
|
try {
|
|
// deleteAssetsAsync shows the OS delete confirmation when the photo is
|
|
// in the gallery. false = user cancelled it → keep everything.
|
|
const ok = await MediaLibraryLegacy.deleteAssetsAsync([entry.assetId]);
|
|
if (!ok) {
|
|
Alert.alert('Photo Kept', 'Android did not confirm the deletion, so the photo stays here and in your gallery.');
|
|
return;
|
|
}
|
|
remove();
|
|
deleteCache();
|
|
} catch (e) {
|
|
console.warn('Gallery delete failed:', e);
|
|
Alert.alert('Delete Failed', 'Could not remove the photo from your gallery. It stays here too.');
|
|
}
|
|
};
|
|
|
|
// Viewer SAVE & DONE: photo was exported to cache only — copy it into the
|
|
// gallery now and remember its assetId so a later trash can remove it.
|
|
const handleSavePhoto = async (id: string) => {
|
|
const entry = photoHistory.find((p) => p.id === id);
|
|
if (!entry || entry.savedToLibrary) return;
|
|
const perm = await MediaLibraryLegacy.requestPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert('Not Saved', 'Media library access was denied, so this photo was not saved to your gallery.');
|
|
return;
|
|
}
|
|
try {
|
|
const asset = await MediaLibraryLegacy.createAssetAsync(entry.uri);
|
|
setPhotoHistory((h) => h.map((p) => (p.id === id ? { ...p, savedToLibrary: true, assetId: asset.id } : p)));
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
} catch (e) {
|
|
console.error('Save to gallery failed:', e);
|
|
Alert.alert('Save Failed', 'Could not save this photo to your gallery.');
|
|
}
|
|
};
|
|
|
|
// Tool-rail toggle: closing the panel (or switching tabs) also drops the open
|
|
// parameter row — otherwise a stale slider row (and its image-drag target)
|
|
// would survive into the next panel.
|
|
const handleToggleTab = (tab: TabId) => {
|
|
setOpenTab((prev) => (prev === tab ? null : tab));
|
|
setOpenParamKey(null);
|
|
// The eyedropper belongs to the HSL row: leave the tab (or close the panel)
|
|
// and the layer goes with it, so an invisible pick layer cannot sit over
|
|
// the picture swallowing pan and zoom.
|
|
setPicking(false);
|
|
};
|
|
|
|
// While a panel parameter row is open, a vertical drag anywhere on the live
|
|
// camera image adjusts that same parameter (the open slider follows). Open
|
|
// row wins over the locked-AE EV drag; a plain tap still locks focus.
|
|
const imageAdjustTarget = (() => {
|
|
if (mode !== 'camera' || openTab !== 'iq' && openTab !== 'wb' && openTab !== 'filters') return null;
|
|
if (!openParamKey) return null;
|
|
const list = openTab === 'iq' ? PARAM_DEFS.iq : openTab === 'wb' ? PARAM_DEFS.wb : PARAM_DEFS.filters;
|
|
const def = list.find((d) => d.key === openParamKey);
|
|
if (!def) return null;
|
|
return {
|
|
key: def.key,
|
|
min: def.min,
|
|
max: def.max,
|
|
step: def.step,
|
|
value: def.get(adjustments),
|
|
onChange: (v: number) => handleUpdateAdjustments(def.set(v)),
|
|
};
|
|
})();
|
|
|
|
return (
|
|
<View className="flex-1 bg-black">
|
|
<StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
|
|
|
|
{/* Fullscreen hero: photo or live preview fills the whole screen */}
|
|
<View className="absolute inset-0 bg-black">
|
|
<Viewfinder
|
|
ref={viewfinderRef}
|
|
mode={mode}
|
|
facing={cameraFacing}
|
|
recipe={previewRecipe}
|
|
selectedFrame={shown.frame}
|
|
useGeotag={shown.useGeotag}
|
|
gpsInfo={gpsInfo}
|
|
libraryImageUri={libraryImageUri}
|
|
availableHeight={deckTop}
|
|
topInset={topInset}
|
|
photoRotation={shown.photoRotation}
|
|
photoStraighten={shown.photoStraighten}
|
|
cameraPermissionGranted={cameraPermissionGranted}
|
|
onRequestCameraPermission={requestCameraPermission}
|
|
// One lens, two camera libraries: while the QR sheet is up its own
|
|
// scanner owns the camera and this session has to let go.
|
|
paused={scanVisible}
|
|
photoOutput={photoOutput}
|
|
rawOutput={rawPhotoOutput}
|
|
rawEnabled={rawEnabled}
|
|
aspectRatio={aspectRatio}
|
|
cropRatio={shown.cropRatio}
|
|
cropApplied={shown.cropApplied}
|
|
cropRect={shown.cropRect}
|
|
onCropRectChange={(r) => commit(true, () => setCropRect(r))}
|
|
onSessionError={handleCameraSessionError}
|
|
onCameraReady={handleCameraReady}
|
|
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
|
|
meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined}
|
|
imageAdjustTarget={imageAdjustTarget}
|
|
onComparePeek={peekArmed ? handlePeek : undefined}
|
|
// Touch-to-shutter: tap the live image -> focus locks -> capture.
|
|
onTouchShutter={touchToShutter ? handleCapture : undefined}
|
|
customWm={shown.customWm}
|
|
wmRotation={shown.wmRotation}
|
|
gpsWm={shown.gpsWm}
|
|
onGpsWmChange={(x, y, size) => commit(true, () => setGpsWm((g) => ({ ...g, x, y, size })))}
|
|
onWmSizeChange={(size) => commit(true, () => setCustomWm((p) => ({ ...p, size })))}
|
|
wallLandscape={shown.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 === 'frame' && wmPanelOpen}
|
|
frameTabActive={openTab === 'frame'}
|
|
onWmPositionChange={(x, y) => commit(true, () => setCustomWm((p) => ({ ...p, x, y })))}
|
|
onWmEdit={() => {
|
|
// Double-tap on the mark re-opens its editor (and with it the
|
|
// placement layer), then focuses the input.
|
|
setOpenTab('frame');
|
|
setOpenParamKey(null);
|
|
setWmEditNonce((n) => n + 1);
|
|
}}
|
|
// HSL eyedropper: the viewer owns the pick layer and hangs the panel
|
|
// it is handed; the state behind both is the HSL row's, above.
|
|
picking={picking}
|
|
hslPanelAt={hslPickedAt}
|
|
onPickColor={pickColor}
|
|
hslPanel={
|
|
openTab === 'hsl' && hslSample && hslPickedAt ? (
|
|
<HslPickPanel
|
|
sample={hslSample}
|
|
bandLabel={HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}
|
|
band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]}
|
|
onKnob={setBandKnob}
|
|
onClose={() => setHslPickedAt(null)}
|
|
/>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</View>
|
|
|
|
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
|
|
<TopBar
|
|
mode={mode}
|
|
recipeName={peeking ? previewRecipe.name : activeRecipe.name}
|
|
onSaveRecipe={handleSaveRecipe}
|
|
onOverwriteRecipe={selectedRecipe?.isCustom ? handleOverwriteRecipe : undefined}
|
|
onEditRecipe={
|
|
selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined
|
|
}
|
|
onExportShare={handleExportShare}
|
|
onUndo={handleUndo}
|
|
undoDepth={undoDepth}
|
|
// Capture-time LED flash: offered only on a lens that carries a flash
|
|
// unit, and only in camera mode (the library save never fires the
|
|
// camera). Tap cycles OFF -> AUTO -> ON -> OFF.
|
|
flashMode={mode === 'camera' && flashAvailable ? flashMode : undefined}
|
|
onCycleFlash={
|
|
mode === 'camera' && flashAvailable
|
|
? () => setFlashMode((m) => (m === 'off' ? 'auto' : m === 'auto' ? 'on' : 'off'))
|
|
: undefined
|
|
}
|
|
onHeight={setTopInset}
|
|
/>
|
|
|
|
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
|
<View
|
|
className="absolute left-0 right-0 bottom-0 z-10"
|
|
style={{ paddingBottom: keyboardHeight }}
|
|
onLayout={(e) => setDeckTop(e.nativeEvent.layout.y)}
|
|
>
|
|
{openTab !== null && (
|
|
<View
|
|
style={{
|
|
backgroundColor: 'rgba(8,8,10,0.96)',
|
|
borderTopWidth: 1,
|
|
borderTopColor: 'rgba(63,63,70,0.7)',
|
|
}}
|
|
className="rounded-t-2xl overflow-hidden border-l border-r border-zinc-800/60"
|
|
>
|
|
<AdjustmentPanel
|
|
activeTab={openTab}
|
|
recipes={recipes}
|
|
currentRecipeId={selectedRecipe?.id || ''}
|
|
adjustments={shownAdjustments}
|
|
onResetAll={handleResetAll}
|
|
resetDirty={resetDirty}
|
|
selectedFrame={shown.frame}
|
|
cropRatio={shown.cropRatio}
|
|
onUpdateCropRatio={(r) => commit(false, () => setCropRatio(r))}
|
|
cropApplied={shown.cropApplied}
|
|
onApplyCrop={() => commit(false, () => setCropApplied(cropRatio))}
|
|
cropEnabled={
|
|
mode === 'library' &&
|
|
(selectedFrame === 'none' ||
|
|
selectedFrame === 'classic-white' ||
|
|
selectedFrame === 'cinematic')
|
|
}
|
|
photoRotation={shown.photoRotation}
|
|
photoStraighten={shown.photoStraighten}
|
|
onPickRotation={(d) => commit(false, () => setPhotoRotation(d))}
|
|
onStraighten={(d) => commit(true, () => setPhotoStraighten(d))}
|
|
onAutoStraighten={handleAutoStraighten}
|
|
wallLandscape={shown.wallLandscape}
|
|
onToggleWallLandscape={() => commit(false, () => setWallLandscape((v) => !v))}
|
|
useGeotag={shown.useGeotag}
|
|
pro={pro}
|
|
openParam={openParamKey}
|
|
onOpenParamChange={setOpenParamKey}
|
|
currentBaseFilter={selectedRecipe?.baseFilter}
|
|
monoOn={monoOn}
|
|
onToggleMono={handleToggleMono}
|
|
onCreateRecipe={handleCreateRecipe}
|
|
editingRecipe={editingRecipe}
|
|
onCloseEdit={() => setEditingRecipe(null)}
|
|
onUpdateRecipe={handleUpdateRecipe}
|
|
onSelectRecipe={handleSelectRecipe}
|
|
favoriteIds={favoriteIds}
|
|
onToggleFavorite={handleToggleFavorite}
|
|
onUpdateAdjustments={handleUpdateAdjustments}
|
|
onUpdateFrame={handleUpdateFrame}
|
|
onToggleGeotag={handleToggleGeotag}
|
|
onSaveRecipe={handleSaveRecipe}
|
|
onImportRecipe={handleImportRecipe}
|
|
onScanRecipe={() => setScanVisible(true)}
|
|
recipeLimitReached={liteSaveBlocked}
|
|
onRecipeLimit={promptRecipeLimit}
|
|
onDeleteRecipe={handleDeleteRecipe}
|
|
customWm={shown.customWm}
|
|
onUpdateCustomWm={handleUpdateCustomWm}
|
|
gpsWm={shown.gpsWm}
|
|
onUpdateGpsWm={(wm) => commit(true, () => setGpsWm(wm))}
|
|
onAcceptCustomWm={handleAcceptCustomWm}
|
|
onCancelCustomWm={handleCancelCustomWm}
|
|
wmEditNonce={wmEditNonce}
|
|
wmRotation={shown.wmRotation}
|
|
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
|
|
onWmPanelChange={setWmPanelOpen}
|
|
hslBand={hslBand}
|
|
onHslBand={setHslBand}
|
|
picking={picking}
|
|
onPicking={setPicking}
|
|
hslSample={hslSample}
|
|
onSetHslGlobal={setHslGlobal}
|
|
// A live preview has no pixel to read, so PICK is library-only.
|
|
canPick={mode === 'library'}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
|
|
|
|
{/* Library picker (grid -> zoomable preview -> USE / CANCEL) */}
|
|
<LibraryPickerModal
|
|
visible={libraryPickerVisible}
|
|
onCancel={() => setLibraryPickerVisible(false)}
|
|
onSelect={handleLibraryPicked}
|
|
/>
|
|
|
|
{/* QR sheet: reads a recipe link off another screen (the landing page's
|
|
card) and imports the file it points at. */}
|
|
<QrScanModal
|
|
visible={scanVisible}
|
|
onCancel={() => setScanVisible(false)}
|
|
onScanned={handleScanRecipe}
|
|
/>
|
|
|
|
<CameraControls
|
|
lastPhotoUri={lastPhotoUri}
|
|
onCapture={handleCapture}
|
|
onBurstStart={mode === 'camera' ? handleBurstStart : undefined}
|
|
onBurstStop={handleBurstStop}
|
|
onPickImage={handlePickImage}
|
|
onOpenPreview={openLatestViewer}
|
|
onOpenSettings={() => setSettingsVisible(true)}
|
|
onFlipCamera={() => setCameraFacing((f) => (f === 'back' ? 'front' : 'back'))}
|
|
isLibraryMode={mode === 'library'}
|
|
onSwitchToCamera={() => handleSetMode('camera')}
|
|
/>
|
|
</View>
|
|
|
|
{/* Gear sheet: shutter sound / ratio+RAW / metering mode */}
|
|
<SettingsModal
|
|
visible={settingsVisible}
|
|
onClose={() => setSettingsVisible(false)}
|
|
aspectRatio={aspectRatio}
|
|
onAspectRatio={handleAspectRatio}
|
|
rawEnabled={rawEnabled}
|
|
onRawEnabled={handleRawEnabled}
|
|
cameraMode={mode === 'camera'}
|
|
meterMode={meterMode}
|
|
onMeterModeChange={setMeterMode}
|
|
shutterSound={shutterSound}
|
|
onShutterSound={setShutterSound}
|
|
touchToShutter={touchToShutter}
|
|
onTouchToShutter={setTouchToShutter}
|
|
gpsEnabled={gpsEnabled}
|
|
onGpsEnabled={handleToggleGps}
|
|
startupMode={startupMode}
|
|
onStartupMode={handleStartupMode}
|
|
pro={pro}
|
|
onUpgrade={() => setShowPro(true)}
|
|
/>
|
|
|
|
{/* The paywall: the only place the tier is sold. */}
|
|
<ProModal visible={showPro} onClose={() => setShowPro(false)} />
|
|
|
|
{/* Session photo viewer (swipe across captured/exported photos) */}
|
|
<PhotoViewerModal
|
|
visible={viewerIndex !== null && photoHistory.length > 0}
|
|
photos={photoHistory}
|
|
initialIndex={viewerIndex ?? 0}
|
|
onClose={() => setViewerIndex(null)}
|
|
onDelete={handleDeletePhoto}
|
|
onSave={handleSavePhoto}
|
|
/>
|
|
|
|
{/* Global Processing Loader Overlay */}
|
|
{isProcessing && (
|
|
<View className="absolute inset-0 bg-black/75 flex items-center justify-center space-y-4">
|
|
<ActivityIndicator size="large" color="#f59e0b" />
|
|
<Text className="text-amber-500 font-mono text-sm tracking-widest font-bold">
|
|
RENDERING PHOTO...
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|