Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library

Both flavors build from one JS tree: `lite` hands out the free apk
(com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label
"RecipesCam Pro"). Release is signed from android/keystore.properties, both
files gitignored - lose either and no later build can install over this one.
The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs
rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor
alone would ship a Pro apk that thinks it is Lite.

The dev scaffolding goes: the three src/dev probes and their App effects, the
EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix
side-by-side install hook. 'balanced' stays the shipped capture mode, which is
what those probes measured on the 12S Ultra.

Looking at the picture: WB now converts kelvin through the Planckian locus into
a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old
one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a
recipe loads every knob it carries over the defaults and stays editable, and
RESET hands the panel back to the sim's own values. The in-app library picker,
the ultra-wide native module and recipe share/import land here too.

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
This commit is contained in:
2026-09-14 19:48:44 +07:00
parent 99405514c6
commit 97949e449d
49 changed files with 2050 additions and 659 deletions
+231 -110
View File
@@ -1,9 +1,9 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform, Keyboard } from 'react-native';
import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import * as Sharing from 'expo-sharing';
import * as FileSystem from 'expo-file-system/legacy';
import * as MediaLibrary from 'expo-media-library';
import * as MediaLibraryLegacy from 'expo-media-library/legacy';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -15,16 +15,19 @@ 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 { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } 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 { getCurrentGPS, requestLocationPermissions, reverseGeocode } from './src/utils/locationUtils';
import { resolvePhotoGPS } from './src/utils/photoGps';
import { resolvePhotoGPS, PickedPhotoAsset } from './src/utils/photoGps';
import { processAndExportPhoto } from './src/utils/exportEngine';
import { processAndExportPhotoNative } from './src/utils/nativeExport';
import { exportRecipeXml, importRecipeXml } from './src/utils/recipeShare';
import { PARAM_DEFS } from './src/utils/paramDefs';
import { LITE_MARK, activatePro, loadPro, proLookInUse } from './src/utils/entitlement';
import { LITE_MARK, LITE_RECIPE_LIMIT, activatePro, loadPro, proLookInUse } from './src/utils/entitlement';
import UltraWide from './modules/recipescam-ultrawide';
// @ts-ignore
import './global.css';
@@ -49,14 +52,27 @@ const CAMERA_SETTINGS_KEY = 'camera_settings';
// decides which engine ships.
const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1';
// B1: the shutter's CameraX capture mode. 'balanced' ships as the default —
// measured on the 12S Ultra at the same 3000x4000, a frame takes ~340 ms
// against ~540 ms for 'quality', with no resolution loss, because this device
// has ZSL (supportsSpeedQualityPrioritization) and 'balanced'/speed ride it.
// EXPO_PUBLIC_CAPTURE_MODE still overrides, but ONLY in dev bundles: Metro
// inlines EXPO_PUBLIC_*, the release HBC build does not (B0 finding) — never
// gate shipped behaviour on it.
const CAPTURE_MODE =
process.env.EXPO_PUBLIC_CAPTURE_MODE === 'speed' || process.env.EXPO_PUBLIC_CAPTURE_MODE === 'quality'
? process.env.EXPO_PUBLIC_CAPTURE_MODE
: 'balanced';
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: flushes the EV to
// the hardware AE bias right before each capture (live slider drags never
// touch the hardware — that flashed the Xiaomi preview).
// 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
@@ -87,6 +103,11 @@ export default function App() {
useEffect(() => {
loadPro().then(setPro);
}, []);
// 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[]>([]);
@@ -223,11 +244,17 @@ export default function App() {
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);
// 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');
// 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');
@@ -255,7 +282,7 @@ export default function App() {
// Full-resolution photo output — attached to the live <Camera> in Viewfinder.
// Photo capture is resolution-independent from the Skia preview output.
const photoOutput = usePhotoOutput({
qualityPrioritization: 'quality',
qualityPrioritization: CAPTURE_MODE,
});
// Second photo output for RAW DNG sidecars (RATIO panel → RAW on). Kept as
// a live output only while rawEnabled — CameraX cannot bind two ImageCapture
@@ -277,25 +304,6 @@ export default function App() {
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
}, []);
// P0 spike: native decode/encode benchmark (EXPO_PUBLIC_BENCH=1 builds only).
useEffect(() => {
if (process.env.EXPO_PUBLIC_BENCH === '1') {
import('./src/dev/nativeBenchProbe')
.then((m) => m.nativeBenchProbe())
.catch(() => {});
}
}, []);
// P6 parity probe: same source through legacy Skia and native engines
// (EXPO_PUBLIC_PARITY=1 builds only).
useEffect(() => {
if (process.env.EXPO_PUBLIC_PARITY === '1') {
import('./src/dev/parityProbe')
.then((m) => m.parityProbe())
.catch((e) => console.error('[PARITY] effect failed', e));
}
}, []);
// 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
@@ -363,6 +371,7 @@ export default function App() {
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);
@@ -417,11 +426,15 @@ export default function App() {
imageUri = null;
}
}
if (imageUri) {
// 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 {
setMode(session.mode || 'camera');
// No usable photo (or the user had chosen the camera): open the camera.
setMode('camera');
}
if (session.adjustments)
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
@@ -495,24 +508,25 @@ export default function App() {
const applyRecipe = (recipe: Recipe) => {
setSelectedRecipe(recipe);
setAdjustments(recipe.adjustments);
// 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 film sim swaps the look only: everything the user dialled in on
// the LIGHT/WB/FX tabs and the frame they picked stay exactly as they are.
// A saved recipe still loads its own stored values (that is what saving it
// was for). Same trick as the session restore below: set the recipe without
// applyRecipe, which would snap the sliders back to the sim's canonical set.
// 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) => {
if (!FILM_SIMS.some((s) => s.id === recipe.id)) {
applyRecipe(recipe);
return;
}
setSelectedRecipe({ ...recipe, adjustments, frameId: selectedFrame });
applyRecipe(recipe);
};
// The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe
@@ -722,9 +736,9 @@ export default function App() {
if (!settingsHydratedRef.current) return;
AsyncStorage.setItem(
CAMERA_SETTINGS_KEY,
JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled })
JSON.stringify({ shutterSound, meterMode, rawEnabled, gpsEnabled, touchToShutter })
).catch(() => {});
}, [shutterSound, meterMode, rawEnabled, gpsEnabled]);
}, [shutterSound, 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.
@@ -771,8 +785,8 @@ export default function App() {
};
const handleSaveRecipe = async (name: string) => {
if (!pro) {
promptProFeature('Saving a recipe');
if (liteSaveBlocked) {
promptRecipeLimit();
return;
}
if (!selectedRecipe) return;
@@ -789,6 +803,52 @@ export default function App() {
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 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: 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;
const xml = await FileSystem.readAsStringAsync(picked.assets[0].uri);
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.`);
} 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;
@@ -816,6 +876,10 @@ export default function App() {
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 });
@@ -850,11 +914,11 @@ export default function App() {
// 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; only STORING it is PRO. 'draft' is no free id, so the
// shutter gate already treats the look as the PRO preset it is.
if (!pro) {
// 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 });
promptProFeature('Saving a custom recipe');
promptRecipeLimit();
return;
}
const newRecipe = await saveCustomRecipe(recipe);
@@ -864,7 +928,12 @@ export default function App() {
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);
@@ -898,63 +967,44 @@ export default function App() {
setPhotoStraighten(-tilt);
}, []);
const handlePickImage = async () => {
// 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);
};
// On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us
// a redacted copy (EXIF GPS stripped), which silently kills the photo's own
// coordinates. Request media read + photo-location BEFORE the pick so the
// copy keeps its true EXIF GPS.
if (gpsEnabled && Platform.OS === 'android') {
try {
await MediaLibrary.requestPermissionsAsync(false, ['photo']);
} catch (e) {
console.warn('Pre-pick media+photo-location permission error:', e);
}
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 { gps, permissionDenied } = await resolvePhotoGPS(asset, { useGeotag: gpsEnabled });
setGpsInfo(gps);
if (permissionDenied) {
console.warn('Media library read permission denied — photo GPS unavailable.');
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 1,
exif: true,
// Android 13+ photo picker always returns a redacted cache copy with
// assetId=null — EXIF GPS stripped, MediaStore id lost. The legacy picker
// (ACTION_GET_CONTENT → DocumentsUI) hands back a
// com.android.providers.media.documents uri, so the MediaStore asset id
// survives AND (with ACCESS_MEDIA_LOCATION granted) the file is not
// redacted → tier-1 EXIF / tier-2 MediaStore both work. Always on: the
// photo keeps its own EXIF GPS even when the chip is off at pick time, so
// switching the chip on later still stamps the photo's real place.
legacy: Platform.OS === 'android',
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
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 { 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);
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const activeRecipe: Recipe =
@@ -973,11 +1023,15 @@ export default function App() {
// 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.
const proLook = proLookInUse({
recipeId: selectedRecipe?.id,
frame: selectedFrame,
customWm: customWmApplied.enabled,
});
// PRO unlocks every look, so a paying user has nothing left in use: only a
// Lite session can trip the two gates below.
const proLook = pro
? null
: proLookInUse({
recipeId: selectedRecipe?.id,
frame: selectedFrame,
customWm: customWmApplied.enabled,
});
// 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;
@@ -1045,7 +1099,10 @@ export default function App() {
}
: null;
const handleCapture = async () => {
// `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
@@ -1065,8 +1122,11 @@ export default function App() {
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;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
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.
@@ -1080,13 +1140,30 @@ export default function App() {
}
const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name
const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
let tSensor = 0;
// 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.
const ultraWideShot = viewfinderRef.current?.isUltraWide?.() === true;
let filePath: string;
if (ultraWideShot) {
filePath = await UltraWide.captureAsync();
tSensor = Date.now();
} else {
filePath = (
await photoOutput.capturePhotoToFile(
{ enableShutterSound: shutterSound === 'on' && !opts?.burst },
{ onDidCapturePhoto: () => { tSensor = Date.now(); } },
)
).filePath;
}
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) {
if (rawEnabled && rawPhotoOutput && !opts?.burst && !ultraWideShot) {
try {
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
const rawUri = toUri(rawFile.filePath);
@@ -1281,6 +1358,35 @@ export default function App() {
}
};
// 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') => {
@@ -1457,6 +1563,8 @@ export default function App() {
meteringAdjustments={meteringActive ? meteringOverlay(shownAdjustments) : undefined}
imageAdjustTarget={imageAdjustTarget}
onComparePeek={peekArmed ? handleComparePeek : undefined}
// Touch-to-shutter: tap the live image -> focus locks -> capture.
onTouchShutter={touchToShutter ? handleCapture : undefined}
customWm={customWm}
wmRotation={wmRotation}
gpsWm={gpsWm}
@@ -1487,6 +1595,7 @@ export default function App() {
onEditRecipe={
selectedRecipe?.isCustom ? () => setEditingRecipe(selectedRecipe) : undefined
}
onExportShare={handleExportShare}
/>
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
@@ -1544,6 +1653,7 @@ export default function App() {
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onImportRecipe={handleImportRecipe}
onDeleteRecipe={handleDeleteRecipe}
customWm={customWm}
onUpdateCustomWm={handleUpdateCustomWm}
@@ -1561,9 +1671,18 @@ export default function App() {
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
{/* Library picker (grid -> zoomable preview -> USE / CANCEL) */}
<LibraryPickerModal
visible={libraryPickerVisible}
onCancel={() => setLibraryPickerVisible(false)}
onSelect={handleLibraryPicked}
/>
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onBurstStart={mode === 'camera' ? handleBurstStart : undefined}
onBurstStop={handleBurstStop}
onPickImage={handlePickImage}
onOpenPreview={openLatestViewer}
onOpenSettings={() => setSettingsVisible(true)}
@@ -1586,6 +1705,8 @@ export default function App() {
onMeterModeChange={setMeterMode}
shutterSound={shutterSound}
onShutterSound={setShutterSound}
touchToShutter={touchToShutter}
onTouchToShutter={setTouchToShutter}
gpsEnabled={gpsEnabled}
onGpsEnabled={handleToggleGps}
startupMode={startupMode}