Files
RecipesCam/PLAN.md
T

104 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 → áp lại patch node_modules vision-camera (`HybridPhotoOutput.kt` "RecipesCam patch").
## 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