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.
11 KiB
PLAN — Option 3: thay engine export Skia bằng module native Kotlin
Nhánh: feat/vision-camera-v5 (RecipesCam, /home/locpham/RecipesCam)
Ngày: 2026-09-06. Trạng thái: bắt đầu.
Vấn đề
exportEngine.ts render ~1s/ảnh, sync JSI (MakeImageFromEncoded, makeImageSnapshot + encodeToBytes(JPEG,95), sharpen) → block JS thread khi queue render chạy. Shutter đã được tách khỏi render bằng FIFO queue (exportQueueRef/enqueueExport, commit f588849); phần còn lại = render chạy trên thread nền native, JS/UI không bao giờ nghẽn.
Quyết định đã chốt (user: "theo khuyến nghị hết")
- D1 Chỉ Android (app không có iOS env).
- D2 v1 = xấp xỉ gần, KHÔNG pixel-identical: ColorMatrix + các bước CPU/Canvas. KHÔNG port GPU AGSL (RuntimeShader) ở v1. Skia engine GIỮ làm reference/fallback, không xoá trước parity gate.
- D3 Vehicle = local Expo Module
modules/recipescam-export(expo-modules-core 57.0.6 có sẵn; autolinking quét./modulesmặc định — đã xác minh trong expo-modules-autolinking src). Không cần nitrogen/nitro-image cho code mới (nitro-image vẫn nằm trong deps, không dùng). - D4 Chạy P0 benchmark decode+encode native trên máy Xiaomi trước khi mở rộng pipeline. Nếu CPU path vỡ budget (<~1s/ảnh toàn pipeline, mục tiêu render nền <500ms) thì quay lại cân nhắc GPU.
Kiến trúc đích
- App gọi module native qua promise → hàm chạy trên background (
AsyncFunction ... Coroutine+withContext(Dispatchers.IO)), JS không block. - FIFO: mỗi job 1 lời gọi module; module tự serialize trên executor nếu cần (v1: JS queue hiện có là đủ, mỗi task 1 await module).
- Đầu vào:
sourceUrifile ảnh + tham số recipe (matrix float[], tone params, frameId, geotag text...). Đầu ra: file JPEG (path) hoặc bytes. MediaLibrary save + DPI patch giữ nguyên ở JS. - Legacy engine giữ nguyên file
src/utils/exportEngine.ts; native path phát triển song song, bật qua flag sau parity gate.
Ánh xạ pipeline (thứ tự Skia engine → native)
Đọc từ exportEngine.ts (512 dòng) — thứ tự xử lý bắt buộc giữ nguyên để ảnh gần engine cũ:
| # | Skia (nguồn) | Native thay thế | Parity |
|---|---|---|---|
| 1 | aspect crop center-largest (trừ wallframe) | Bitmap crop Bitmap.createBitmap(src, x,y,w,h) |
exact |
| 2 | matrix màu: getSkiaColorMatrix + applyExposureGain → ColorFilter.MakeMatrix |
port toán colorUtils.ts → Kotlin float[4x5] → ColorMatrixColorFilter |
exact nếu giữ thứ tự phép nhân & rounding; test diff |
| 3 | tone TONE_SKSL DR/highlight/shadow (toneShader.ts) |
v1: đường cong LUT 1D/luminance xấp xỉ | approx |
| 4 | cinema CINEMA_SKSL seasonal grade |
v1: bỏ qua hoặc matrix approx | approx (chú thích) |
| 5 | denoise blur | v1: skip / downscale-upscale blur | approx |
| 6 | clarity 3x3 conv sharpen (âm = mist blur) | v1: conv nguyên thuỷ trên IntArray nếu >0; mist = blur | approx (watch perf) |
| 7 | grain overlay noise shader BlendMode.Overlay | noise bitmap tile + PorterDuff Overlay | approx |
| 8 | frames: polaroid card (POLAROID_CARD/polaroidLayout), wallframe (wallframe.png, xoay 90, wallframeLayout), classic borders (drawFrameOnCanvas) |
Android Canvas draw; port layout constants từ frameUtils.ts | close |
| 9 | GPS watermark: font Cousine + NotoEmoji-GPS, 📍/📷, amber #f59e0b/trắng, vị trí tuỳ framedWindow | Canvas + Typeface.createFromFile |
close (metrics emoji khác Skia → QA) |
| 10 | screen sharpen (screenSharpenImage) |
v1: conv 3x3 native hoặc skip | approx |
| 11 | encode encodeToBytes(JPEG,95) |
Bitmap.compress(JPEG, 95) |
exact (size có thể lệch nhẹ, chấp nhận) |
| 12 | DPI patch patchJpegDpi(bytes,300) (jpegDpi.ts) |
GIỮ ở JS trên bytes/phản hồi | exact |
| 13 | ghi file + MediaLibrary.createAssetAsync (App.tsx) |
giữ nguyên JS | exact |
Nguồn hằng số: src/utils/colorUtils.ts (matrices provia/velvia/classic-chrome/astia/eterna/classic-neg/leica/monochrome, kelvinToRGB, WB/tint, CC), src/utils/toneShader.ts, src/utils/cinemaShader.ts, src/utils/frameUtils.ts (POLAROID_CARD, polaroidLayout, WALLFRAME_W/H 3117/4000, wallframeLayout), src/utils/exportEngine.ts, src/utils/jpegDpi.ts.
Phases
P0 — Spike: module skeleton + benchmark decode/encode native
Việc:
- Tạo local Expo module
modules/recipescam-export(android only) với 1 hàmdecodeEncodeAsync(srcPath, dstPath, quality)→ Map{decodeMs, encodeWriteMs, totalMs}; decode BitmapFactory, encode Bitmap.compress, ghi file, toàn bộwithContext(Dispatchers.IO). - Probe tạm
src/dev/nativeBenchProbe.ts+ hook App.tsx gatedprocess.env.EXPO_PUBLIC_BENCH === '1'(chạy 3 lần trên wallframe.png + đo gap setInterval để chứng minh JS không block). - Build release với
EXPO_PUBLIC_BENCH=1 ./gradlew assembleRelease→ APK. - Chạy trên Xiaomi (USB), đọc logcat ReactNativeJS. Exit: decode+encode < ~500ms; JS gap nhỏ khi render. Đây là gate D4. File sinh: modules/recipescam-export/*, src/dev/nativeBenchProbe.ts (xoá khi P7).
P1 — Port matrix màu
- Kotlin: dịch
getSkiaColorMatrix+applyExposureGain(colorUtils.ts) → FloatArray 4x5. Tham số baseFilter + ColorAdjustments truyền từ JS (hoặc gửi matrix đã tính từ JS — JS tính nhanh, matrix 4x5 ~ 20 số — QUYẾT ĐỊNH: tính matrix ở JS bằng colorUtils.ts hiện có rồi truyền float[] xuống native → KHÔNG nhân đôi logic, parity miễn bàn). Cùng kiểu: kelvinToRGB/WB/CC vẫn nằm trong getSkiaColorMatrix → chỉ cần gửi kết quả. - Native:
processColorAsync(srcPath, dstPath, matrix: FloatArray, crop: {…}|null)decode → crop →ColorMatrixColorFilterqua Paint/Canvas draw → encode. Exit: ảnh ra khớp Skia matrix (so sánh bằng mắt + diff).
P2 — Tone (DR/HL/SH) xấp xỉ
- Port công thức toneShader.ts sang dạng LUT áp per-pixel (hoặc 2 pass nếu cần giữ grain sau tone — thứ tự: tone trước clarity/grain như engine). Exit: vùng highlight/shadow không cháy như engine cũ, chấp nhận sai số.
P3 — Clarity/grain/denoise/cinema
- Theo bảng: conv 3x3 IntArray (clarity), blur xấp xỉ (denoise/mist), noise tile Overlay (grain). Cinema: quyết định giữ matrix approx hay bỏ. Exit: ảnh gần engine cũ, tổng thời gian nền OK (đo trên máy).
P4 — Frames + watermark (Canvas)
- polaroid card, wallframe (asset wallframe.png — đọc qua đường file/asset), classic borders, GPS text/emoji. Exit: layout khớp frameUtils (kiểm tra pixel các góc/viền bằng overlay ảnh so sánh).
P5 — Hook vào luồng export thật + flag
- App.tsx: thêm nhánh
useNativeExport(flag hằng/env); giữ nguyên enqueueExport FIFO; MediaLibrary/DPI giữ nguyên. Exit: chụp → gallery có ảnh qua native path.
P6 — Parity + perf + fallback
- Script chạy cùng 1 ảnh nguồn qua 2 engine; xuất ảnh ghép cạnh nhau + điểm diff (downscale) để QA trên máy.
- Đo: cũ ~1s/ảnh block JS → mới <500ms nền.
- Giữ legacy:
processAndExportPhotocũ nguyên vẹn, bật qua cờ. Exit: user QA đạt → mặc định native; legacy chỉ fallback.
P7 — Dọn & ship
- Xoá probe/dev code, xoá env gate; typecheck; commit + push (KHÔNG stage
.kilo/,.expo/);assembleRelease; báo MD5 APK. - Nhắc: nếu npm install chạy lại → node_modules vision-camera về pristine, áp lại patch:
cd RecipesCam && git apply patches/vision-camera-shutter-silent.patch(artifact = diffHybridPhotoOutput.kt: shutter OFF thật silent — bỏ OR mustPlayShutterSound).cd RecipesCam && git apply patches/vision-camera-isp-off.patch(artifact = 3 hunk, 2 file:HybridPhotoOutput.ktcho mọi still JPEG,modules/recipescam-ultrawide/.../UltraWideModule.ktcho đường 0.5x — cùng cặp cờ NOISE_REDUCTION_MODE + EDGE_MODE của ISP = OFF).
Rủi ro / ghi chú
- Local module phải bắt chước đúng cấu trúc expo package (đã đọc expo-media-library:
plugins { id 'com.android.library'; id 'expo-module-gradle-plugin' }, expo-module.config.json khai báo class Kotlin; autolinking quét./modules). - API expo-modules-core 57:
AsyncFunction("x") Coroutine { }(như MediaLibraryModule), coroutines 1.10.2 quaapicủa expo-modules-core. - Gradle: thêm module → lần build đầu sẽ reconfigure (chậm hơn), không cần sửa settings.gradle.
- 12MP full-res decode ~48MB RAM — ổn, không inSampleSize.
- Emoji trong Canvas có metrics khác Skia — kiểm tra vị trí watermark.
- Grain procedural (Skia hash noise) ≠ noise tile — chấp nhận sai khác nhỏ ở v1.
- Không đặt tên module trùng class đã có.
Checklist trạng thái
- Chốt quyết định D1–D4
- P0 scaffold: module
modules/recipescam-export+ probesrc/dev/nativeBenchProbe.ts+ hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink) - P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS)
- P1 matrix (processColorAsync: decode→crop→ColorMatrixColorFilter→JPEG; parity OK trên Xiaomi — classic-neg/black → rgb(13,5,0), translate×255, crop 2000x2000, off-thread, colorMs≈30ms)
- P2 tone (applyTone CPU pass port TONE_SKSL — immutable-decode fix: copy ARGB_8888 trước setPixels; gates 64x64 trên Xiaomi: white hl=-1→140, gray64 sh=+1→75, white dr=1→209, gray26 sh=+1→104, gray26 hl=+1 sh=-1→11 — ALL PASS; full 12MP toneMs 488-548ms = cần tối ưu khi gộp P3)
- P3 clarity/grain (native denoise/clarity/grain/cinema port: boxBlur padding fix + step gates; cinema gates summer-192→(211,209,201), winter-32→(82,85,88), summer-sat→(217,120,47) khớp node ±0; ramp/step blur gates denoise→130, clarity→131, mist→130/denoise-step→170, mist-step→142 PASS; grain structural OK; full 12MP 4.7-5.1s CPU = vượt D4, quyết GPU ở P6)
- P4 frames/watermark
- P5 hook export + flag
- P6 parity/perf gate — probe chạy xong trên Xiaomi (
1fdf765): classic-neg / retro-amber-frame / cinema-summer trên ảnh real 2304x3072; native off-thread 2.7-2.9s (JS ticks 65-70 vẫn chạy) vs legacy GPU 0.36-0.5s (JS-block); diff meanAbs 7.1/13.1/13.3, gt12% 16.8/42.1/42.7, gt32% 0.14/3.7/4.0 (grain procedural + JPEG encoder + tone approx; retro thấp nhất — mist + frame classic). CHỜ user QA ảnh ghép DCIM/parity_*.jpg → flip default native + quyết GPU - P7 dọn + ship
Ngoài P-series (feature branch)
- Startup mode DEFAULT/LAST SESSION (Settings chip + AsyncStorage
startup_mode; restore session qua FILM_SIMS; fix clobber ratio khi sim selected — 3 file LF) - GPS watermark mặc định OFF khi startup DEFAULT (applyDefaultStartup setUseGeotag(false)) — verify UI Xiaomi: cold start DEFAULT → FRAME tab GPS WATERMARK OFF
- Shutter OFF thật silent (patch HybridPhotoOutput.kt, 2 path, artifact patches/) — verify log ON/OFF trên Xiaomi
- Zoom camera native: probe physical zoom + zoom UI (đang làm)