P0 native decode/encode benchmark passes on Xiaomi (12MP decode+encode ~210-270ms, off-main-thread)

This commit is contained in:
2026-09-09 15:10:30 +07:00
parent a8932f52ed
commit dfcf0f3a28
5 changed files with 157 additions and 40 deletions
+3 -1
View File
@@ -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(() => {});
}
}, []);
+103
View File
@@ -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
@@ -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<String, Int> {
val timings = mutableMapOf<String, Int>()
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
}
+3
View File
@@ -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<DecodeEncodeTimings>;
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;
}
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;
+15 -39
View File
@@ -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<void> {
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);
}