Add aspect-ratio composition band and RAW DNG sidecar capture

This commit is contained in:
2026-09-08 18:43:02 +07:00
parent a16c8734a8
commit d43405a4de
6 changed files with 371 additions and 65 deletions
+117 -5
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
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';
@@ -14,7 +15,7 @@ import CameraControls from './src/components/CameraControls';
import ToolRail, { TabId } from './src/components/ToolRail';
import PreviewModal from './src/components/PreviewModal';
import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types';
import { Recipe, ColorAdjustments, FrameId, GPSInfo, AspectRatio } 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';
@@ -37,6 +38,10 @@ export default function App() {
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);
const rawFailRef = useRef(false);
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
const [openTab, setOpenTab] = useState<TabId | null>(null);
@@ -53,6 +58,15 @@ export default function App() {
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}`);
@@ -74,6 +88,7 @@ export default function App() {
adjustments,
selectedFrame,
useGeotag,
aspectRatio,
selectedRecipeId: selectedRecipe?.id || null,
gpsInfo,
};
@@ -85,7 +100,7 @@ export default function App() {
}
});
return () => subscription.remove();
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]);
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, aspectRatio]);
const initSession = async () => {
const list = await getAllRecipes();
@@ -121,6 +136,7 @@ export default function App() {
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.gpsInfo) setGpsInfo(session.gpsInfo);
if (session.selectedRecipeId) {
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
@@ -189,6 +205,66 @@ export default function App() {
}
};
const handleAspectRatio = (ratio: AspectRatio) => {
setAspectRatio(ratio);
if (openTab === 'ratio') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
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);
}
console.error(`[raw] saved DNG to album ${albumName}`);
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({
@@ -318,6 +394,25 @@ export default function App() {
console.error(`[capture] shot-took ${Date.now() - tShot}ms`);
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 tRaw = Date.now();
const rawFile = await rawPhotoOutput.capturePhotoToFile({}, {});
console.error(`[capture] raw-shot-took ${Date.now() - tRaw}ms`);
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);
}
}
const tExp = Date.now();
const result = await processAndExportPhoto(
sourceUri,
@@ -327,7 +422,13 @@ export default function App() {
captureGps,
// 300dpi header + full-res screen sharpen. evFromCamera: the AE bias
// already applied exposure compensation at capture — don't add 2^EV.
{ dpi: 300, sharpen: true, evFromCamera: true }
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
{
dpi: 300,
sharpen: true,
evFromCamera: true,
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
}
);
console.error(`[capture] export-took ${Date.now() - tExp}ms (pipeline total ${Date.now() - tShot}ms)`);
@@ -367,7 +468,9 @@ export default function App() {
},
selectedFrame,
useGeotag,
gpsInfo
gpsInfo,
// Library export follows the same composition ratio as the viewfinder band.
{ aspect: aspectRatio === 'full' ? undefined : aspectRatio }
);
setIsProcessing(false);
@@ -416,6 +519,10 @@ export default function App() {
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
photoOutput={photoOutput}
rawOutput={rawPhotoOutput}
rawEnabled={rawEnabled}
aspectRatio={aspectRatio}
onSessionError={handleCameraSessionError}
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
/>
</View>
@@ -441,6 +548,11 @@ export default function App() {
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
aspectRatio={aspectRatio}
rawEnabled={rawEnabled}
cameraMode={mode === 'camera'}
onAspectRatio={handleAspectRatio}
onRawEnabled={handleRawEnabled}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}