diff --git a/.gitignore b/.gitignore index 6d4ecab..db241e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .expo/ +modules/*/android/build/ diff --git a/App.tsx b/App.tsx index aa0cccc..b99c6c8 100644 --- a/App.tsx +++ b/App.tsx @@ -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) => { diff --git a/modules/recipescam-export/android/build.gradle b/modules/recipescam-export/android/build.gradle new file mode 100644 index 0000000..80c2702 --- /dev/null +++ b/modules/recipescam-export/android/build.gradle @@ -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" + } +} diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt new file mode 100644 index 0000000..7733fef --- /dev/null +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -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 { + val timings = mutableMapOf() + 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 +} diff --git a/modules/recipescam-export/expo-module.config.json b/modules/recipescam-export/expo-module.config.json new file mode 100644 index 0000000..0ea5e00 --- /dev/null +++ b/modules/recipescam-export/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "platforms": ["android"], + "android": { + "modules": [ + "com.locphamtran.recipescamera.export.RecipescamExportModule" + ] + } +} diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts new file mode 100644 index 0000000..020765f --- /dev/null +++ b/modules/recipescam-export/index.ts @@ -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; +} + +export default requireNativeModule('RecipesCamExport') as RecipescamExportModule; diff --git a/modules/recipescam-export/package.json b/modules/recipescam-export/package.json new file mode 100644 index 0000000..250da8f --- /dev/null +++ b/modules/recipescam-export/package.json @@ -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 +} diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts new file mode 100644 index 0000000..a87163e --- /dev/null +++ b/src/dev/nativeBenchProbe.ts @@ -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 { + 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); + } +}