Files
RecipesCam/PLAN.md
T

9.7 KiB
Raw Blame History

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

  • Chốt quyết định D1–D4
  • 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)
  • 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
  • P7 dọn + ship