perf(capture): trade CameraX MFNR for latency, average our own low-light burst off the shutter path

Measured on the 12S Ultra (2203121C), same scene, 1x, EV 0, 3 shots per arm:

- qualityPrioritization 'quality' -> 'balanced': tap->file 1013 ms vs 736 ms
  (-277 ms, -27%), settled 1552 ms vs 1280 ms, in-camera 521 ms vs 286 ms.
  Repeat on a second session: -228 ms. Image quality at ISO ~450 is inside the
  scatter of one arm (flat sigma +2.4%, edge +0.1%, texture -1.1%), so the MFNR
  pass quality would have bought is not measurable here. Also removes the
  EXPO_PUBLIC_CAPTURE_MODE seam: Metro inlines EXPO_PUBLIC_* in dev only, the
  release HBC build does not, so it could never gate shipped behaviour.
- Dark scenes now go through the app's own burst (ISO >= 100 gate for the probe)
  and mergeBurst runs in the export queue instead of on the shutter path, so a
  second shot is accepted while the frames are still being averaged: double-tap
  at 1.2 s used to drop the second photo, now it lands. Output stays the merged
  frame (flat sigma 0.825 vs 0.939 for a single frame).

Burst is skipped when the LED is the light source, when the shot came from the
native 0.5x session, for a held burst, for RAW, and below the devicePerf RAM
floor (deviceFloor.ts) where three raws plus the alignment rasters would be
seconds of shutter lag.

Bundles in the work this sits on: frame averaging (burstAlign/burstMerge),
the per-device floor, and the EXIF/ISO readback the burst gate needs (exifWrite,
photoMeta, exportEngine, RecipescamExportModule).

Self-checks: src/utils/{burstAlign,deviceFloor,exifWrite}.check.ts.
This commit is contained in:
2026-09-26 11:37:48 +07:00
parent 9f7d83276d
commit 588fbf77d9
14 changed files with 800 additions and 15 deletions
+55
View File
@@ -0,0 +1,55 @@
// The floor the whole pipeline is budgeted against: the cheapest Android that
// clears this app's minimum. What sets the minimum: minSdk 26 (below it
// vision-camera's NativeBufferHelper throws "HardwareBuffers require minSdk 26
// or higher!" on every frame), a 32-bit armeabi-v7a build (the shipped APK
// carries both ABIs), Camera2 with EXIF, and Skia's GL backend, which links
// -lGLESv2 (see node_modules/@shopify/react-native-skia/android/CMakeLists.txt).
// Every API 26 phone clears the GL bar, so the floor is the Android Go, 1 GB
// class: Redmi Go (Snapdragon 425, Adreno 308, 5.8-7.2 GFLOPS / 5.3 GB/s,
// 8 MP), Nokia 1 (MT6737M, Mali-T720, 5 MP) and Galaxy J2 Core (Exynos 7570,
// 8 MP) — 4x Cortex-A53 at 1.1-1.4 GHz, 1 GB RAM, 8 GB storage, 480p-720p
// screen. Nothing weaker can install.
//
// What that floor affords, at 4 bytes per RGBA8888 pixel:
// 8 MP raster 32 MB -> the export's live surfaces alone = 96 MB
// one full-frame pass 64 MB of traffic @ 5.3 GB/s ~ 12 ms
// 4 slow A53 cores ~1/6 of a current flagship core
// Hence, here: one resample at the door instead of a full-resolution surface
// chain, and no 3-frame burst (six 8 MP decodes plus a CPU alignment on four
// slow cores is seconds of shutter lag on the device class least able to afford
// it). The live preview needs no cap — Viewfinder already forces the frame
// stream to the 4:3 size nearest 1280x960.
//
// No state and no native import: the RAM reading comes from
// photoMeta.devicePerf() and is passed in, so this module also runs under plain
// node (see deviceFloor.check.ts).
export const FLOOR_RAM_MB = 2048;
export const FLOOR_EXPORT_PIXELS = 4_000_000;
// ramMb 0 = the platform would not say. Unknown is never a floor device: the app
// must not downgrade a phone it could not measure.
export const isFloorDevice = (ramMb: number): boolean => ramMb > 0 && ramMb <= FLOOR_RAM_MB;
// Most pixels the export may paint. 4 MP (2336x1752) still prints 10x15 cm at
// 300 dpi and halves every surface behind it — the 12S Ultra's 12 MP capture is
// untouched, since it is not a floor device.
// ponytail: downscale rather than tile — tile the render the day a floor user
// needs the full 8 MP file.
export const maxExportPixels = (ramMb: number): number =>
isFloorDevice(ramMb) ? FLOOR_EXPORT_PIXELS : Infinity;
// ponytail: one capability, all or nothing — add a 2-frame CPU-lite merge if a
// floor device ever reports it can afford one.
export const canBurst = (ramMb: number): boolean => !isFloorDevice(ramMb);
// Scale a raster down to at most `cap` pixels, keeping its aspect. Whole pixels
// only: the surface allocator and the EXIF stamp both read these back.
export function floorRaster(
w: number,
h: number,
cap: number
): { width: number; height: number } {
if (!(w > 0 && h > 0) || !(w * h > cap)) return { width: w, height: h };
const s = Math.sqrt(cap / (w * h));
return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) };
}