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
@@ -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
}