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:
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
.expo/
|
||||
modules/*/android/build/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+47
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user