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.
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
import { requireNativeModule } from 'expo-modules-core';
|
|
|
|
export interface DecodeEncodeTimings {
|
|
decodeMs: number;
|
|
encodeWriteMs: number;
|
|
totalMs: number;
|
|
}
|
|
|
|
export interface ColorTimings {
|
|
decodeMs: number;
|
|
toneMs?: number;
|
|
cinemaMs?: number;
|
|
colorMs: number;
|
|
clarityMs?: number;
|
|
grainMs?: number;
|
|
frameMs?: number;
|
|
sharpenMs?: number;
|
|
encodeWriteMs: number;
|
|
totalMs: number;
|
|
}
|
|
|
|
/** Rect (pixel coords) of the center-largest aspect crop, computed in JS. */
|
|
export interface CropRect {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
/** ToneUniforms from toneShader.ts (0/0/0 = skip tone pass). */
|
|
export interface ToneUniforms {
|
|
dr: number;
|
|
hl: number;
|
|
sh: number;
|
|
}
|
|
|
|
/** Optional pipeline params passed as a map (native Coroutine arity cap = 8). */
|
|
export interface PhotoAdjust {
|
|
/** ToneUniforms from toneShader.ts; default 0/0/0 = skip tone pass. */
|
|
toneDr?: number;
|
|
toneHl?: number;
|
|
toneSh?: number;
|
|
/** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */
|
|
cinema?: number[] | null;
|
|
/** ColorAdjustments.denoise -10..10; >0 blur, <0 re-grain (0 = skip). */
|
|
denoise?: number;
|
|
/** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */
|
|
clarity?: number;
|
|
/** ColorAdjustments.grain 0..10 (0 = skip). */
|
|
grain?: number;
|
|
/** JPEG quality 0..100; default 95. */
|
|
quality?: number;
|
|
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
|
|
sharpen?: number;
|
|
/** Soften blur radius for a negative sharpening knob (exportEngine #4); >0 applies. */
|
|
soften?: number;
|
|
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
|
|
dpi?: number;
|
|
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
|
|
aspect?: '4:3' | '3:2' | 'full';
|
|
/** Photo frame preset applied before encode, mirroring frameUtils.ts. Default 'none'. */
|
|
frameId?: 'none' | 'classic-white' | 'cinematic' | 'polaroid' | 'wallframe';
|
|
/** Base64 PNG art (wallframe only, 4000x3117). Decoded per call. */
|
|
frameArtB64?: string;
|
|
/** Base64 TTF body font (geo stamp only). Cached after first decode. */
|
|
fontB64?: string;
|
|
/** Base64 TTF emoji font (geo stamp only). Cached after first decode. */
|
|
iconB64?: string;
|
|
/** Optional GPS stamp {locality, timestamp}; null/omitted = skip. */
|
|
geo?: { locality: string; timestamp: number } | null;
|
|
/** Encode PNG instead of JPEG. Probe-only. */
|
|
png?: boolean;
|
|
/** Probe-only: sample these photo-space pixel coords after frame pass, before encode; logs via samplePixel. */
|
|
probeSamples?: Array<{ x: number; y: number }>;
|
|
/** Probe-only: log per-row stamp diff/amber/white metrics (see samplePixel) and return them in ColorTimings-adjacent logs. */
|
|
probeStampDiff?: boolean;
|
|
}
|
|
|
|
export interface RecipescamExportModule {
|
|
/** Decode JPEG/PNG at srcPath (plain absolute path), re-encode JPEG q and write dstPath. All native, background. Returns stage timings in ms. */
|
|
decodeEncodeAsync(srcPath: string, dstPath: string, quality: number): Promise<DecodeEncodeTimings>;
|
|
/**
|
|
* Native pipeline mirroring exportEngine.ts filter order — decode, optional
|
|
* center-largest crop, optional tone pass (TONE_SKSL port), optional cinema
|
|
* seasonal grade (CINEMA_SKSL port), row-major 4x5 color matrix (translate
|
|
* column in 0..1 Skia space), optional denoise blur / clarity conv / mist
|
|
* blur / grain Overlay, then JPEG-encode to dstPath. All native, off the
|
|
* JS/main threads. Stage order mirrors exportEngine: crop -> tone -> cinema
|
|
* -> matrix -> denoise/clarity -> grain -> frame (classic/cinematic/
|
|
* polaroid/wallframe) -> GPS stamp (geo, bottom of the composed output)
|
|
* -> encode. Timings gain frameMs when a frame/stamp pass runs.
|
|
*/
|
|
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
|
|
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
|
|
processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise<ColorTimings>;
|
|
/**
|
|
* Insert an encoded image into DCIM/Camera (MediaStore RELATIVE_PATH) — where
|
|
* the stock camera writes. Returns the MediaStore _ID (expo Asset.id shape),
|
|
* or '' when the insert/copy fails, so JS falls back to MediaLibrary.
|
|
*/
|
|
saveToCameraRollAsync(srcPath: string, mimeType: string): Promise<string>;
|
|
/**
|
|
* Raw device identity the stock Xiaomi camera folds into EXIF 0x889e (the
|
|
* watermark blob HyperOS Gallery reads) but CameraX/HAL never supplies:
|
|
* codename, marketing name, manufacturer, model. Empty strings when absent.
|
|
*/
|
|
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
|
|
/**
|
|
* Device facts the capture and render paths budget against (see
|
|
* src/utils/deviceFloor.ts): total RAM in MB and CPU core count. `ramMb` is 0
|
|
* when the platform refused the read — callers treat 0 as "unknown", never as
|
|
* a floor device, so an unmeasurable phone keeps the full-quality path.
|
|
*/
|
|
devicePerf(): { ramMb: number; cores: number };
|
|
|
|
/**
|
|
* The image a system share handed this launch (AndroidManifest
|
|
* ACTION_SEND — see takeSharedImage in the Kotlin module): the sender's
|
|
* stream is copied into the cache and returned as an ordinary file.
|
|
* `path` is "" when this launch was not a share, or when the share was
|
|
* already taken — the intent is read and cleared exactly once.
|
|
*/
|
|
takeSharedImage(): Promise<{ path: string; assetId: string; type: string }>;
|
|
|
|
/** P0 probe helpers — removed before ship. */
|
|
ping(): Promise<string>;
|
|
materializeBenchAsset(assetName: string): Promise<string>;
|
|
materializeSolidAsset(r: number, g: number, b: number): Promise<string>;
|
|
materializeRampAsset(): Promise<string>;
|
|
materializeStepAsset(): Promise<string>;
|
|
}
|
|
|
|
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;
|