878 lines
35 KiB
TypeScript
878 lines
35 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState, Platform } from 'react-native';
|
|
import { useCameraPermission, usePhotoOutput } from 'react-native-vision-camera';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
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';
|
|
|
|
import TopBar from './src/components/TopBar';
|
|
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
|
|
import AdjustmentPanel 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 { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound } from './src/types';
|
|
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
|
|
import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes';
|
|
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
|
|
import { resolvePhotoGPS } from './src/utils/photoGps';
|
|
import { processAndExportPhoto } from './src/utils/exportEngine';
|
|
import { processAndExportPhotoNative } from './src/utils/nativeExport';
|
|
import { PARAM_DEFS } from './src/utils/paramDefs';
|
|
|
|
// @ts-ignore
|
|
import './global.css';
|
|
|
|
const SESSION_KEY = 'saved_session';
|
|
|
|
// 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';
|
|
|
|
export default function App() {
|
|
const [mode, setMode] = useState<'camera' | 'library'>('camera');
|
|
// 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).
|
|
const viewfinderRef = useRef<ViewfinderHandle>(null);
|
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
|
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
|
|
|
// Custom temporary overrides for the sliders
|
|
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
|
|
|
|
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
|
|
const [useGeotag, setUseGeotag] = useState(true);
|
|
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(null);
|
|
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
|
|
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
|
|
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';
|
|
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
|
|
? {
|
|
highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT),
|
|
shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW),
|
|
contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST),
|
|
clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY),
|
|
}
|
|
: undefined;
|
|
// Same tonal overlay for the camera EXPORT (the EV dip is not inside — the
|
|
// hardware AE bias at capture already carries it, evFromCamera skips software
|
|
// EV, so folding the dip here would darken twice).
|
|
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);
|
|
|
|
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);
|
|
// 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');
|
|
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);
|
|
// 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();
|
|
// 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',
|
|
});
|
|
// 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}`);
|
|
|
|
useEffect(() => {
|
|
// B5: run a single sequenced init — restore the session first, then fall
|
|
// back to the first recipe only when no recipe was restored.
|
|
initSession();
|
|
// 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));
|
|
}
|
|
}, []);
|
|
|
|
// 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,
|
|
meterMode,
|
|
shutterSound,
|
|
selectedRecipeId: selectedRecipe?.id || null,
|
|
gpsInfo,
|
|
};
|
|
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, aspectRatio, meterMode, shutterSound]);
|
|
|
|
const initSession = async () => {
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
|
|
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;
|
|
}
|
|
}
|
|
if (imageUri) {
|
|
setLibraryImageUri(imageUri);
|
|
setMode('library');
|
|
} else {
|
|
setMode(session.mode || 'camera');
|
|
}
|
|
if (session.adjustments)
|
|
setAdjustments({ ...DEFAULT_ADJUSTMENTS, ...session.adjustments });
|
|
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
|
|
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
|
|
if (session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
|
|
if (session.meterMode === 'evaluative' || session.meterMode === 'highlight') setMeterMode(session.meterMode);
|
|
if (session.shutterSound === 'on' || session.shutterSound === 'off') setShutterSound(session.shutterSound);
|
|
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
|
|
if (session.selectedRecipeId) {
|
|
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
|
|
}
|
|
}
|
|
|
|
if (restoredRecipe) {
|
|
applyRecipe(restoredRecipe);
|
|
} else if (list.length > 0) {
|
|
applyRecipe(list[0]);
|
|
}
|
|
};
|
|
|
|
const loadGPS = async () => {
|
|
const info = await getCurrentGPS();
|
|
if (info) {
|
|
setGpsInfo({ ...info, source: 'device' });
|
|
}
|
|
};
|
|
|
|
const applyRecipe = (recipe: Recipe) => {
|
|
setSelectedRecipe(recipe);
|
|
setAdjustments(recipe.adjustments);
|
|
setSelectedFrame(recipe.frameId || 'none');
|
|
setUseGeotag(recipe.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 };
|
|
setAdjustments(next);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
adjustments: next,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdateFrame = (frameId: FrameId) => {
|
|
setSelectedFrame(frameId);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
frameId,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleToggleGeotag = async (enabled: boolean) => {
|
|
setUseGeotag(enabled);
|
|
if (selectedRecipe) {
|
|
setSelectedRecipe({
|
|
...selectedRecipe,
|
|
useGeotag: enabled,
|
|
});
|
|
}
|
|
if (enabled && !gpsInfo) {
|
|
const locationGranted = await requestLocationPermissions();
|
|
if (locationGranted) {
|
|
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);
|
|
}
|
|
};
|
|
|
|
// 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 (!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.`);
|
|
};
|
|
|
|
// 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'>) => {
|
|
const newRecipe = await saveCustomRecipe(recipe);
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
applyRecipe(newRecipe);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
};
|
|
|
|
const handleDeleteRecipe = async (id: string) => {
|
|
await deleteCustomRecipe(id);
|
|
const list = await getAllRecipes();
|
|
setRecipes(list);
|
|
if (selectedRecipe?.id === id && list.length > 0) {
|
|
applyRecipe(list[0]);
|
|
}
|
|
};
|
|
|
|
const handlePickImage = async () => {
|
|
if (isProcessing) return;
|
|
|
|
// On Android 10+, picking without ACCESS_MEDIA_LOCATION makes the OS hand us
|
|
// a redacted copy (EXIF GPS stripped). That silently kills Tier-1 EXIF GPS
|
|
// and the photo's own coordinates (often a different place than where the
|
|
// user stands now) get replaced by a device-GPS fallback. Request media
|
|
// read + photo-location BEFORE the pick so the copy keeps its true EXIF GPS.
|
|
let mediaOk = true;
|
|
if (useGeotag && Platform.OS === 'android') {
|
|
try {
|
|
const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
|
|
mediaOk = perm.granted;
|
|
} catch (e) {
|
|
mediaOk = false;
|
|
console.warn('Pre-pick media+photo-location permission error:', e);
|
|
}
|
|
}
|
|
|
|
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 → photo GPS is
|
|
// unreachable and we'd stamp the device's location instead. 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.
|
|
legacy: Platform.OS === 'android' && useGeotag,
|
|
});
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
const asset = result.assets[0];
|
|
setLibraryImageUri(asset.uri);
|
|
setMode('library');
|
|
// Clear old GPS — will be replaced if the photo (or device) has location data
|
|
setGpsInfo(null);
|
|
|
|
// Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback.
|
|
// Device fallback only when media read was granted (else we cannot know
|
|
// whether the photo carries its own GPS — stamping the device's current
|
|
// spot over a photo from somewhere else would be wrong).
|
|
const { gps, permissionDenied } = await resolvePhotoGPS(asset, {
|
|
useGeotag,
|
|
allowDeviceFallback: useGeotag && mediaOk,
|
|
});
|
|
setGpsInfo(gps);
|
|
if (permissionDenied) {
|
|
console.warn('Media library read permission denied — photo GPS unavailable.');
|
|
}
|
|
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
const handleCapture = async () => {
|
|
if (isProcessing) 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 (!photoOutput) {
|
|
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
|
|
return;
|
|
}
|
|
captureBusyRef.current = true;
|
|
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;
|
|
if (useGeotag && !captureGps) {
|
|
const deviceGps = await getCurrentGPS();
|
|
if (deviceGps) {
|
|
captureGps = { ...deviceGps, source: 'device' as const };
|
|
setGpsInfo(captureGps);
|
|
}
|
|
}
|
|
|
|
// Hardware AE bias for this shot (best-effort, bounded retries inside).
|
|
// Applied here — right before the capture — instead of live during EV
|
|
// slider drags, which re-locked AE on the Xiaomi and flashed preview.
|
|
try {
|
|
await viewfinderRef.current?.flushExposureBias();
|
|
} catch (e) {
|
|
console.warn('EV flush at capture failed:', e);
|
|
}
|
|
const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
|
|
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) {
|
|
try {
|
|
const rawFile = await rawPhotoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
|
|
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.
|
|
const recipeName = activeRecipe.name;
|
|
const render = () => {
|
|
const recipeArgs = {
|
|
...activeRecipe,
|
|
adjustments: renderAdj,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
};
|
|
const options = {
|
|
// 300dpi header + full-res screen sharpen. evFromCamera: the AE bias
|
|
// already applied exposure compensation at capture — don't add 2^EV.
|
|
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
|
|
dpi: 300,
|
|
sharpen: true,
|
|
evFromCamera: true,
|
|
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
|
|
};
|
|
return NATIVE_EXPORT
|
|
? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options)
|
|
: processAndExportPhoto(sourceUri, 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);
|
|
|
|
const result = NATIVE_EXPORT
|
|
? await processAndExportPhotoNative(
|
|
libraryImageUri,
|
|
{
|
|
...selectedRecipe,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
},
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
// Library export follows the same composition ratio as the viewfinder band.
|
|
{ aspect: aspectRatio === 'full' ? undefined : aspectRatio }
|
|
)
|
|
: await processAndExportPhoto(
|
|
libraryImageUri,
|
|
{
|
|
...selectedRecipe,
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
},
|
|
selectedFrame,
|
|
useGeotag,
|
|
gpsInfo,
|
|
// Library export follows the same composition ratio as the viewfinder band.
|
|
{ aspect: aspectRatio === 'full' ? undefined : aspectRatio }
|
|
);
|
|
|
|
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.');
|
|
}
|
|
};
|
|
|
|
// 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 === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
|
|
setGpsInfo(null);
|
|
}
|
|
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);
|
|
};
|
|
|
|
// 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}
|
|
recipe={
|
|
selectedRecipe || {
|
|
id: 'temp',
|
|
name: 'Custom',
|
|
baseFilter: 'none',
|
|
adjustments,
|
|
frameId: selectedFrame,
|
|
useGeotag,
|
|
}
|
|
}
|
|
selectedFrame={selectedFrame}
|
|
useGeotag={useGeotag}
|
|
gpsInfo={gpsInfo}
|
|
libraryImageUri={libraryImageUri}
|
|
cameraPermissionGranted={cameraPermissionGranted}
|
|
onRequestCameraPermission={requestCameraPermission}
|
|
photoOutput={photoOutput}
|
|
rawOutput={rawPhotoOutput}
|
|
rawEnabled={rawEnabled}
|
|
aspectRatio={aspectRatio}
|
|
onSessionError={handleCameraSessionError}
|
|
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
|
|
meteringEvOffset={meteringActive ? METER_HL_EV : 0}
|
|
meteringAdjustments={meteringAdj}
|
|
imageAdjustTarget={imageAdjustTarget}
|
|
/>
|
|
</View>
|
|
|
|
{/* Top overlay: mode label + recipe name (left), Save current (right) */}
|
|
<TopBar mode={mode} recipeName={activeRecipe.name} onSaveRecipe={handleSaveRecipe} />
|
|
|
|
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
|
<View className="absolute left-0 right-0 bottom-0 z-10">
|
|
{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={adjustments}
|
|
selectedFrame={selectedFrame}
|
|
useGeotag={useGeotag}
|
|
openParam={openParamKey}
|
|
onOpenParamChange={setOpenParamKey}
|
|
currentBaseFilter={selectedRecipe?.baseFilter}
|
|
onCreateRecipe={handleCreateRecipe}
|
|
onSelectRecipe={applyRecipe}
|
|
onUpdateAdjustments={handleUpdateAdjustments}
|
|
onUpdateFrame={handleUpdateFrame}
|
|
onToggleGeotag={handleToggleGeotag}
|
|
onSaveRecipe={handleSaveRecipe}
|
|
onDeleteRecipe={handleDeleteRecipe}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
<ToolRail activeTab={openTab} onToggle={handleToggleTab} />
|
|
|
|
<CameraControls
|
|
lastPhotoUri={lastPhotoUri}
|
|
onCapture={handleCapture}
|
|
onPickImage={handlePickImage}
|
|
onOpenPreview={openLatestViewer}
|
|
onOpenSettings={() => setSettingsVisible(true)}
|
|
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}
|
|
/>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|