Add aspect-ratio composition band and RAW DNG sidecar capture
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user