feat(native-export): scaffold local Expo module + native decode/encode bench probe (P0)

Local Kotlin module modules/recipescam-export (autolinked via expo ./modules)
exposes decodeEncodeAsync — BitmapFactory decode -> JPEG q95 encode -> file,
all on Dispatchers.IO off the JS/main threads. EXPO_PUBLIC_BENCH=1 builds run
src/dev/nativeBenchProbe.ts: 3 timed decode+encode passes on the bundled
wallframe artwork plus a JS-timer gap check proving the JS thread never
blocks during native rendering. Legacy Skia engine untouched (reference).
This commit is contained in:
2026-09-09 12:39:35 +07:00
parent f588849ac5
commit a8932f52ed
8 changed files with 157 additions and 0 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules
.expo/
modules/*/android/build/
+7
View File
@@ -139,6 +139,13 @@ export default function App() {
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
}, []);
// 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());
}
}, []);
// Save/restore session on app lifecycle changes
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
@@ -0,0 +1,15 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'com.locphamtran.recipescamera'
version = '0.1.0'
android {
namespace "com.locphamtran.recipescamera.export"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
}
@@ -0,0 +1,47 @@
package com.locphamtran.recipescamera.export
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
class RecipescamExportModule : Module() {
override fun definition() = ModuleDefinition {
Name("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
// never blocked while native rendering runs.
AsyncFunction("decodeEncodeAsync") Coroutine { srcPath: String, dstPath: String, quality: Int ->
decodeEncodeAsync(srcPath, dstPath, quality)
}
}
}
private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map<String, Int> {
val timings = mutableMapOf<String, Int>()
val t0 = System.nanoTime()
withContext(Dispatchers.IO) {
val src = File(srcPath)
check(src.exists()) { "source file missing: $srcPath" }
val t1 = System.nanoTime()
val bmp = BitmapFactory.decodeFile(srcPath)
?: error("decode failed: $srcPath")
timings["decodeMs"] = ((System.nanoTime() - t1) / 1_000_000).toInt()
val t2 = System.nanoTime()
FileOutputStream(dstPath).use { fos ->
check(bmp.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" }
}
timings["encodeWriteMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt()
bmp.recycle()
}
timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt()
return timings
}
@@ -0,0 +1,8 @@
{
"platforms": ["android"],
"android": {
"modules": [
"com.locphamtran.recipescamera.export.RecipescamExportModule"
]
}
}
+14
View File
@@ -0,0 +1,14 @@
import { requireNativeModule } from 'expo-modules-core';
export interface DecodeEncodeTimings {
decodeMs: number;
encodeWriteMs: number;
totalMs: number;
}
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>;
}
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;
+8
View File
@@ -0,0 +1,8 @@
{
"name": "recipescam-export",
"version": "0.1.0",
"description": "Native Kotlin photo export pipeline (decode/process/encode off the JS thread) for Recipescam.",
"main": "index.ts",
"license": "MIT",
"private": true
}
+57
View File
@@ -0,0 +1,57 @@
import { Asset } from 'expo-asset';
import * as FileSystem from 'expo-file-system/legacy';
import RecipescamExport from '../../modules/recipescam-export';
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.
*/
export async function nativeBenchProbe(): Promise<void> {
console.log('[BENCH] start');
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);
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
);
}
console.log('[BENCH] done');
} catch (e) {
console.error('[BENCH] failed', e);
}
}