588fbf77d9
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.
88 lines
3.8 KiB
TypeScript
88 lines
3.8 KiB
TypeScript
// Gallery-facing photo metadata: the RC_ filename convention and the file-level
|
|
// EXIF helpers both export engines share (the codec itself lives in exifWrite.ts).
|
|
import { File } from 'expo-file-system';
|
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
import RecipescamExport from '../../modules/recipescam-export';
|
|
import { isFloorDevice } from './deviceFloor';
|
|
import { writeJpegExif, SOFTWARE, type DeviceIdentity, type ExifStamp } from './exifWrite';
|
|
|
|
// RC_<yyyymmdd_hhmmss>[_exported].jpg — the name the gallery shows for a saved
|
|
// photo. The exported variant marks a photo re-exported from the library.
|
|
export function rcPhotoName(exported: boolean, date = new Date()): string {
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
const stamp = `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
return `RC_${stamp}${exported ? '_exported' : ''}.jpg`;
|
|
}
|
|
|
|
// Cache path for the next export. Two renders landing in the same second would
|
|
// collide on the name AND on RN's <Image> cache (keyed by URI), so the
|
|
// timestamp walks forward a second at a time instead of taking a suffix.
|
|
export function nextPhotoPath(exported: boolean): string {
|
|
let ms = Date.now();
|
|
for (;;) {
|
|
const path = `${FileSystem.cacheDirectory}${rcPhotoName(exported, new Date(ms))}`;
|
|
if (!new File(path).exists) return path;
|
|
ms += 1000;
|
|
}
|
|
}
|
|
|
|
export async function readPhotoBytes(uri: string): Promise<Uint8Array | null> {
|
|
try {
|
|
return await new File(uri).bytes();
|
|
} catch (e) {
|
|
console.warn('EXIF source read failed:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Device identity for the Gallery watermark blob (exifWrite.watermarkBlob).
|
|
// Read once — the props cannot change while the app runs. null when the native
|
|
// module is older than this call, so stamping still works without it.
|
|
let deviceIdentity: DeviceIdentity | null | undefined;
|
|
export function getDeviceIdentity(): DeviceIdentity | null {
|
|
if (deviceIdentity === undefined) {
|
|
try {
|
|
deviceIdentity = RecipescamExport.deviceInfo();
|
|
} catch (e) {
|
|
console.warn('deviceInfo unavailable:', e);
|
|
deviceIdentity = null;
|
|
}
|
|
}
|
|
return deviceIdentity;
|
|
}
|
|
|
|
// Device facts the capture and render paths budget against (deviceFloor.ts):
|
|
// total RAM and CPU cores. Lives next to deviceInfo because that is where the
|
|
// native handle already is; read once, the numbers cannot change while the app
|
|
// runs. ramMb 0 = the platform refused, which deviceFloor reads as "not a floor
|
|
// device" so an unmeasurable phone keeps the full-quality path.
|
|
let devicePerfCache: { ramMb: number; cores: number } | null = null;
|
|
export function devicePerf(): { ramMb: number; cores: number } {
|
|
if (!devicePerfCache) {
|
|
try {
|
|
devicePerfCache = RecipescamExport.devicePerf();
|
|
} catch (e) {
|
|
console.warn('devicePerf unavailable, full-quality path:', e);
|
|
devicePerfCache = { ramMb: 0, cores: 0 };
|
|
}
|
|
// One line, so a floor device in the field can be confirmed from logcat:
|
|
// "adb logcat -s ReactNativeJS | grep floor".
|
|
const { ramMb, cores } = devicePerfCache;
|
|
console.info(`devicePerf: ramMb=${ramMb} cores=${cores} floor=${isFloorDevice(ramMb)}`);
|
|
}
|
|
return devicePerfCache;
|
|
}
|
|
|
|
// Stamp EXIF into a file already on disk (the native engine writes the JPEG
|
|
// itself, so those bytes never pass through JS). Never throws — a metadata
|
|
// failure must not cost the user the photo.
|
|
export async function stampExifOnFile(fileUri: string, stamp: ExifStamp, sourceUri?: string | null): Promise<void> {
|
|
try {
|
|
const target = new File(fileUri);
|
|
const source = sourceUri ? await readPhotoBytes(sourceUri) : null;
|
|
target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, device: getDeviceIdentity(), ...stamp }, source));
|
|
} catch (e) {
|
|
console.warn('EXIF stamp failed:', e);
|
|
}
|
|
}
|