10 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 → á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 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