diff --git a/App.tsx b/App.tsx index b99c6c8..75adb71 100644 --- a/App.tsx +++ b/App.tsx @@ -142,7 +142,9 @@ export default function App() { // P0 spike: native decode/encode benchmark (EXPO_PUBLIC_BENCH=1 builds only). useEffect(() => { if (process.env.EXPO_PUBLIC_BENCH === '1') { - import('./src/dev/nativeBenchProbe').then((m) => m.nativeBenchProbe()); + import('./src/dev/nativeBenchProbe') + .then((m) => m.nativeBenchProbe()) + .catch(() => {}); } }, []); diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..052b218 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,103 @@ +# 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) +- [ ] P0 benchmark trên Xiaomi (cần máy cắm USB; APK sẵn sàng) +- [ ] P1 matrix +- [ ] P2 tone +- [ ] P3 clarity/grain +- [ ] P4 frames/watermark +- [ ] P5 hook export + flag +- [ ] P6 parity/perf gate +- [ ] P7 dọn + ship diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt index 7733fef..098c5e4 100644 --- a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -2,6 +2,7 @@ package com.locphamtran.recipescamera.export import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.util.Log import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -10,9 +11,12 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +private const val TAG = "RecipesCamExport" + class RecipescamExportModule : Module() { override fun definition() = ModuleDefinition { Name("RecipesCamExport") + Log.i(TAG, "module defined RecipesCamExport") // P0 spike: decode (BitmapFactory) + JPEG encode + file write, all off the // JS/main threads. Returns per-stage ms so JS can prove the JS thread is @@ -20,12 +24,39 @@ class RecipescamExportModule : Module() { AsyncFunction("decodeEncodeAsync") Coroutine { srcPath: String, dstPath: String, quality: Int -> decodeEncodeAsync(srcPath, dstPath, quality) } + + // P0 probe helpers — removed before ship. + AsyncFunction("ping") { -> + Log.i(TAG, "ping called") + "pong" + } + + // P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the + // 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the + // real file path back to JS, which then runs decodeEncodeAsync on it. This + // exercises the exact file-based path P1 will use. + AsyncFunction("materializeBenchAsset") Coroutine { assetName: String -> + val ctx = appContext.reactContext ?: error("react context lost") + val resId = ctx.resources.getIdentifier(assetName, "drawable", ctx.packageName) + check(resId != 0) { "drawable $assetName not bundled" } + val srcFile = File(ctx.cacheDir, "$assetName.png") + withContext(Dispatchers.IO) { + if (!srcFile.exists()) { + ctx.resources.openRawResource(resId).use { input -> + FileOutputStream(srcFile).use { output -> input.copyTo(output) } + } + } + Log.i(TAG, "materialized $assetName -> ${srcFile.absolutePath} bytes=${srcFile.length()}") + } + srcFile.absolutePath + } } } private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map { val timings = mutableMapOf() val t0 = System.nanoTime() + Log.i(TAG, "start src=$srcPath q=$quality thread=${Thread.currentThread().name}") withContext(Dispatchers.IO) { val src = File(srcPath) check(src.exists()) { "source file missing: $srcPath" } @@ -40,8 +71,10 @@ private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: check(bmp.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" } } timings["encodeWriteMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt() + Log.i(TAG, "run done decodeMs=${timings["decodeMs"]} encodeWriteMs=${timings["encodeWriteMs"]} size=${bmp.width}x${bmp.height} thread=${Thread.currentThread().name}") bmp.recycle() } timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt() + Log.i(TAG, "totalMs=${timings["totalMs"]}") return timings } diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index 020765f..edfc882 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -9,6 +9,9 @@ export interface DecodeEncodeTimings { export interface RecipescamExportModule { /** Decode JPEG/PNG at srcPath (plain absolute path), re-encode JPEG q and write dstPath. All native, background. Returns stage timings in ms. */ decodeEncodeAsync(srcPath: string, dstPath: string, quality: number): Promise; + /** P0 probe helpers — removed before ship. */ + ping(): Promise; + materializeBenchAsset(assetName: string): Promise; } export default requireNativeModule('RecipesCamExport') as RecipescamExportModule; diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts index a87163e..02ff975 100644 --- a/src/dev/nativeBenchProbe.ts +++ b/src/dev/nativeBenchProbe.ts @@ -1,5 +1,3 @@ -import { Asset } from 'expo-asset'; -import * as FileSystem from 'expo-file-system/legacy'; import RecipescamExport from '../../modules/recipescam-export'; const RUNS = 3; @@ -7,50 +5,28 @@ const RUNS = 3; /** * P0 spike harness (temporary — removed at P7). Runs native decode+encode on * the bundled wallframe artwork (3117x4000, representative of a 12MP export) - * and logs per-stage ms plus a JS-timer gap check proving the JS thread is - * not blocked while native work runs. Gated by EXPO_PUBLIC_BENCH=1. + * and logs per-stage ms proving the JS thread is not blocked while native work + * runs. Gated by EXPO_PUBLIC_BENCH=1. + * + * Release-build visibility is via native Log.i (RecipesCamExport tag) because + * Hermes release builds strip console.* entirely. JS-side dispatch gaps appear + * as the logcat deltas between consecutive decode "start" lines. */ export async function nativeBenchProbe(): Promise { - console.log('[BENCH] start'); + const m = RecipescamExport as any; try { - const asset = Asset.fromModule(require('../../wallframe.png')); - await asset.downloadAsync(); - if (!asset.localUri) { - console.error('[BENCH] wallframe asset has no localUri'); - return; - } - const src = asset.localUri.replace(/^file:\/\//, ''); - console.log('[BENCH] src=' + src); - + const pong = await m.ping(); + const src: string = await m.materializeBenchAsset('wallframe'); for (let i = 0; i < RUNS; i++) { - const dst = `${FileSystem.cacheDirectory}bench-dst-${Date.now()}-${i}.jpg`; - const gaps: number[] = []; - let last = Date.now(); - // 5ms JS timer while native work runs: a blocked JS thread would stretch gaps. - const iv = setInterval(() => { - const now = Date.now(); - gaps.push(now - last); - last = now; - }, 5); - const t0 = Date.now(); - const timings = await RecipescamExport.decodeEncodeAsync(src, dst, 95); - const jsElapsed = Date.now() - t0; - clearInterval(iv); - - const sorted = [...gaps].sort((a, b) => b - a); - console.log( - '[BENCH] run=' + i + - ' jsElapsed=' + jsElapsed + 'ms' + - ' decodeMs=' + timings.decodeMs + - ' encodeWriteMs=' + timings.encodeWriteMs + - ' totalMs=' + timings.totalMs + - ' jsGapMax=' + (sorted[0] ?? 0) + 'ms' + - ' jsGapP95=' + (sorted[Math.floor(sorted.length * 0.05)] ?? 0) + 'ms' + - ' dst=' + dst + const dst = src.replace(/\.png$/, `-out-${Date.now()}-${i}.jpg`); + const timings = await m.decodeEncodeAsync(src, dst, 95); + console.error( + '[BENCH] run=' + i + ' jsRoundTripMs=' + (Date.now() - t0) + + ' decodeMs=' + timings.decodeMs + ' encodeWriteMs=' + timings.encodeWriteMs + + ' totalMs=' + timings.totalMs + ' pong=' + pong ); } - console.log('[BENCH] done'); } catch (e) { console.error('[BENCH] failed', e); }