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
|
node_modules
|
||||||
.expo/
|
.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
|
// 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
|
// Save/restore session on app lifecycle changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
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