P0 native decode/encode benchmark passes on Xiaomi (12MP decode+encode ~210-270ms, off-main-thread)
This commit is contained in:
@@ -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(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
+33
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user