perf(capture): render exports off the shutter path via FIFO background queue

This commit is contained in:
2026-09-09 12:22:07 +07:00
parent bd7efce600
commit f588849ac5
+62 -33
View File
@@ -95,6 +95,22 @@ export default function App() {
const [shutterSound, setShutterSound] = useState<ShutterSound>('on'); const [shutterSound, setShutterSound] = useState<ShutterSound>('on');
const photoSeq = useRef(0); const photoSeq = useRef(0);
const [isProcessing, setIsProcessing] = useState(false); 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) // Hardware Permissions (vision-camera)
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission(); const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
@@ -435,7 +451,7 @@ export default function App() {
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.'); Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
return; return;
} }
setIsProcessing(true); captureBusyRef.current = true;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
try { try {
// A photo taken now is at the device's current spot — resolve device GPS // A photo taken now is at the device's current spot — resolve device GPS
@@ -449,7 +465,6 @@ export default function App() {
} }
} }
const tShot = Date.now();
// Hardware AE bias for this shot (best-effort, bounded retries inside). // Hardware AE bias for this shot (best-effort, bounded retries inside).
// Applied here — right before the capture — instead of live during EV // Applied here — right before the capture — instead of live during EV
// slider drags, which re-locked AE on the Xiaomi and flashed preview. // slider drags, which re-locked AE on the Xiaomi and flashed preview.
@@ -478,42 +493,56 @@ export default function App() {
} }
} }
const tExp = Date.now(); // Snapshot the look at shutter time, then render OFF the shutter path:
const result = await processAndExportPhoto( // processAndExportPhoto (~1s Skia render + gallery save) is enqueued on
sourceUri, // the FIFO background queue — the shutter returns in tens of ms and the
{ ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag }, // next shot can be taken immediately; no full-screen RENDERING overlay.
selectedFrame, // Finished photos land in history (and the gallery) when their turn
useGeotag, // completes; the thumbnail button shows each as it arrives.
captureGps, const recipeName = activeRecipe.name;
// 300dpi header + full-res screen sharpen. evFromCamera: the AE bias const render = () =>
// already applied exposure compensation at capture — don't add 2^EV. processAndExportPhoto(
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native). sourceUri,
{ { ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag },
dpi: 300, selectedFrame,
sharpen: true, useGeotag,
evFromCamera: true, captureGps,
aspect: aspectRatio === 'full' ? undefined : aspectRatio, // 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,
}
);
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.');
} }
); });
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: activeRecipe.name,
savedToLibrary: result.savedToLibrary,
assetId: result.assetId,
});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
}
} catch (error) { } catch (error) {
console.error('Camera capture failed:', error); console.error('Camera capture failed:', error);
Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.'); Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.');
} finally { } finally {
setIsProcessing(false); captureBusyRef.current = false;
} }
return; return;
} }