# 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 `./modules` mặ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: `sourceUri` file ả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àm `decodeEncodeAsync(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 gated `process.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 → `ColorMatrixColorFilter` qua 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: `processAndExportPhoto` cũ 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 = diff `HybridPhotoOutput.kt`: shutter OFF thật silent — bỏ OR mustPlayShutterSound). ## 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 qua `api` củ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 - [x] Chốt quyết định D1–D4 - [x] P0 scaffold: module `modules/recipescam-export` + probe `src/dev/nativeBenchProbe.ts` + hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink) - [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS) - [x] 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) - [x] 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) - [x] 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) - [x] P4 frames/watermark - [x] 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) - [x] Startup mode DEFAULT/LAST SESSION (Settings chip + AsyncStorage `startup_mode`; restore session qua FILM_SIMS; fix clobber ratio khi sim selected — 3 file LF) - [x] GPS watermark mặc định OFF khi startup DEFAULT (applyDefaultStartup setUseGeotag(false)) — verify UI Xiaomi: cold start DEFAULT → FRAME tab GPS WATERMARK OFF - [x] 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)