diff --git a/PLAN.md b/PLAN.md index 15e1292..2fc1fde 100644 --- a/PLAN.md +++ b/PLAN.md @@ -95,7 +95,7 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback. - [x] P0 scaffold: module `modules/recipescam-export` + probe `src/dev/nativeBenchProbe.ts` + hook App.tsx (env-gated); release build OK (recipescam-export 0.1.0 autolink) - [x] P0 benchmark trên Xiaomi (12MP wallframe: decode 152-182ms, encode+write 56-77ms, total 215-269ms, off-main-thread — PASS) - [x] P1 matrix (processColorAsync: decode→crop→ColorMatrixColorFilter→JPEG; parity OK trên Xiaomi — classic-neg/black → rgb(13,5,0), translate×255, crop 2000x2000, off-thread, colorMs≈30ms) -- [ ] P2 tone +- [x] P2 tone (applyTone CPU pass port TONE_SKSL — immutable-decode fix: copy ARGB_8888 trước setPixels; gates 64x64 trên Xiaomi: white hl=-1→140, gray64 sh=+1→75, white dr=1→209, gray26 sh=+1→104, gray26 hl=+1 sh=-1→11 — ALL PASS; full 12MP toneMs 488-548ms = cần tối ưu khi gộp P3) - [ ] P3 clarity/grain - [ ] P4 frames/watermark - [ ] P5 hook export + flag 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 index a0642fd..f67cd17 100644 --- 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 @@ -13,6 +13,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import kotlin.math.max +import kotlin.math.roundToInt private const val TAG = "RecipesCamExport" @@ -28,12 +30,19 @@ class RecipescamExportModule : Module() { decodeEncodeAsync(srcPath, dstPath, quality) } - // P1: apply a 4x5 color matrix (already folded in JS from colorUtils.ts — - // getSkiaColorMatrix + applyExposureGain) to a decoded image, optionally - // after a center-largest aspect crop, then JPEG-encode. All native, all off - // the JS/main threads. crop: {x, y, width, height} or null. + // P1/P2 pipeline: decode -> optional crop -> optional tone (DR/hl/sh) -> + // color matrix -> JPEG encode, all native, all off the JS/main threads. + // matrix: row-major 4x5, translate column in 0..1 Skia space (folded from + // colorUtils.ts in JS). crop: {x,y,width,height}|null. toneDr/toneHl/ + // toneSh: ToneUniforms from toneShader.ts (0/0/0 = skip tone pass). + AsyncFunction("processPhotoAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map?, toneDr: Double, toneHl: Double, toneSh: Double, quality: Int -> + processPipeline(srcPath, dstPath, matrix, crop, toneDr, toneHl, toneSh, quality) + } + + // P1 alias (kept for the on-device matrix parity probe): matrix + crop, no + // tone pass. AsyncFunction("processColorAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map?, quality: Int -> - processColorAsync(srcPath, dstPath, matrix, crop, quality) + processPipeline(srcPath, dstPath, matrix, crop, 0.0, 0.0, 0.0, quality) } // P0 probe helpers — removed before ship. @@ -62,41 +71,42 @@ class RecipescamExportModule : Module() { srcFile.absolutePath } - // P1 probe helper: materialize a solid-black 64x64 PNG into cacheDir. - // Color matrix parity check: a matrix with only translate offsets applied to - // black must yield (offset*255) per channel — verifies the native filter - // uses the same offset space as Skia's MakeMatrix. - AsyncFunction("materializeBlackAsset") Coroutine { -> + // P1/P2 probe helper: materialize a solid RGB 64x64 PNG into cacheDir + // (black/white/gray ramps used to gate tone/matrix math on-device). + AsyncFunction("materializeSolidAsset") Coroutine { r: Int, g: Int, b: Int -> val ctx = appContext.reactContext ?: error("react context lost") - val srcFile = File(ctx.cacheDir, "bench_black.png") + val srcFile = File(ctx.cacheDir, "bench_solid_${r}_${g}_${b}.png") withContext(Dispatchers.IO) { if (!srcFile.exists()) { val bmp = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) - bmp.eraseColor(android.graphics.Color.BLACK) + bmp.eraseColor(android.graphics.Color.rgb(r, g, b)) FileOutputStream(srcFile).use { fos -> check(bmp.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" } } bmp.recycle() } - Log.i(TAG, "black asset -> ${srcFile.absolutePath}") + Log.i(TAG, "solid asset -> ${srcFile.absolutePath}") } srcFile.absolutePath } } } -private suspend fun processColorAsync( +private suspend fun processPipeline( srcPath: String, dstPath: String, matrixList: List<*>, crop: Map?, + toneDr: Double, + toneHl: Double, + toneSh: Double, quality: Int, ): Map { require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" } val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f } val timings = mutableMapOf() val t0 = System.nanoTime() - Log.i(TAG, "processColor start src=$srcPath crop=$crop thread=${Thread.currentThread().name}") + Log.i(TAG, "processPhoto start src=$srcPath crop=$crop tone=($toneDr,$toneHl,$toneSh) thread=${Thread.currentThread().name}") withContext(Dispatchers.IO) { val srcFile = File(srcPath) check(srcFile.exists()) { "source file missing: $srcPath" } @@ -118,10 +128,27 @@ private suspend fun processColorAsync( Log.i(TAG, "cropped to ${bmp.width}x${bmp.height}") } - // 2. Color matrix via paint pass onto a fresh ARGB_8888 bitmap. + // 2. Tone pass (DR / highlight / shadow). Port of TONE_SKSL from + // toneShader.ts — pure per-pixel function of luma, so no GPU needed: + // decode to IntArray, factor f(t), write back. Alpha untouched. + val t2 = System.nanoTime() + var toneActive = toneDr != 0.0 || toneHl != 0.0 || toneSh != 0.0 + if (toneActive) { + // decodeFile/crop bitmaps are immutable; applyTone mutates in place -> copy. + if (!bmp.isMutable) { + val mutable = bmp.copy(Bitmap.Config.ARGB_8888, true) + bmp.recycle() + bmp = mutable + } + applyTone(bmp, toneDr.toFloat(), toneHl.toFloat(), toneSh.toFloat()) + timings["toneMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt() + Log.i(TAG, "tone applied dr=$toneDr hl=$toneHl sh=$toneSh thread=${Thread.currentThread().name}") + } + + // 3. Color matrix via paint pass onto a fresh ARGB_8888 bitmap. // Skia/Android share row-major 4x5 semantics; the 5th column (translate) // lives in 0..255 space on Android but 0..1 on Skia -> scale by 255 here. - val t2 = System.nanoTime() + val t3 = System.nanoTime() val androidMatrix = FloatArray(20) { i -> if (i % 5 == 4) matrix[i] * 255f else matrix[i] } @@ -129,25 +156,71 @@ private suspend fun processColorAsync( val canvas = Canvas(out) val paint = Paint().apply { colorFilter = ColorMatrixColorFilter(androidMatrix) } canvas.drawBitmap(bmp, 0f, 0f, paint) - timings["colorMs"] = ((System.nanoTime() - t2) / 1_000_000).toInt() + timings["colorMs"] = ((System.nanoTime() - t3) / 1_000_000).toInt() val center = out.getPixel(out.width / 2, out.height / 2) Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})") bmp.recycle() - // 3. JPEG encode + write. - val t3 = System.nanoTime() + // 4. JPEG encode + write. + val t4 = System.nanoTime() FileOutputStream(dstPath).use { fos -> check(out.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" } } - timings["encodeWriteMs"] = ((System.nanoTime() - t3) / 1_000_000).toInt() - Log.i(TAG, "processColor done dst=$dstPath size=${out.width}x${out.height} decodeMs=${timings["decodeMs"]} colorMs=${timings["colorMs"]} encodeWriteMs=${timings["encodeWriteMs"]} thread=${Thread.currentThread().name}") + timings["encodeWriteMs"] = ((System.nanoTime() - t4) / 1_000_000).toInt() + Log.i(TAG, "processPhoto done dst=$dstPath size=${out.width}x${out.height} decodeMs=${timings["decodeMs"]} toneMs=${timings["toneMs"] ?: 0} colorMs=${timings["colorMs"]} encodeWriteMs=${timings["encodeWriteMs"]} thread=${Thread.currentThread().name}") out.recycle() } timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt() - Log.i(TAG, "processColor totalMs=${timings["totalMs"]}") + Log.i(TAG, "processPhoto totalMs=${timings["totalMs"]}") return timings } +private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) { + val w = bmp.width + val h = bmp.height + val px = IntArray(w * h) + bmp.getPixels(px, 0, w, 0, 0, w, h) + val n = px.size + for (i in 0 until n) { + val c = px[i] + val r = ((c shr 16) and 0xFF) / 255f + val g = ((c shr 8) and 0xFF) / 255f + val b = (c and 0xFF) / 255f + val t = (0.2126f * r + 0.7152f * g + 0.0722f * b).coerceIn(0f, 1f) + val hlMask = smoothstep(0.80f, 1.00f, t) + val shMask = 1f - smoothstep(0.00f, 0.30f, t) + // NOTE: engine keeps 'out' as a reserved-word-free local. + var o = t + // Highlight: bright end only, multiplicative on both signs (symmetric region). + if (hl >= 0f) { + o *= 1f + hl * 0.30f * hlMask + } else { + o *= 1f + hl * 0.45f * hlMask + } + // Shadow: dark end only. + if (sh >= 0f) { + o += sh * 0.80f * shMask * (1f - t) + } else { + o *= 1f + sh * 0.80f * shMask + } + // Dynamic range: gentle shadow lift + highlight roll (protect brights). + o += dr * 0.12f * shMask * (1f - t) + o -= dr * 0.18f * hlMask * t + val f = (o / max(t, 0.0004f)).coerceIn(0f, 4f) + val alpha = (c ushr 24) and 0xFF + val nr = ((r * f).coerceIn(0f, 1f) * 255f).roundToInt() + val ng = ((g * f).coerceIn(0f, 1f) * 255f).roundToInt() + val nb = ((b * f).coerceIn(0f, 1f) * 255f).roundToInt() + px[i] = (alpha shl 24) or (nr shl 16) or (ng shl 8) or nb + } + bmp.setPixels(px, 0, w, 0, 0, w, h) +} + +private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float { + val tx = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f) + return tx * tx * (3f - 2f * tx) +} + private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map { val timings = mutableMapOf() val t0 = System.nanoTime() diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index 8a92b0b..8e93d3f 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -8,6 +8,7 @@ export interface DecodeEncodeTimings { export interface ColorTimings { decodeMs: number; + toneMs?: number; colorMs: number; encodeWriteMs: number; totalMs: number; @@ -21,20 +22,29 @@ export interface CropRect { height: number; } +/** ToneUniforms from toneShader.ts (0/0/0 = skip tone pass). */ +export interface ToneUniforms { + dr: number; + hl: number; + sh: 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; /** - * Decode srcPath, optional crop rect, apply a row-major 4x5 color matrix - * (translate column in 0..1 Skia space — folded from colorUtils.ts - * getSkiaColorMatrix/applyExposureGain in JS), JPEG-encode to dstPath. - * All native, off the JS/main threads. + * Decode srcPath, optional crop rect, optional tone pass (DR/highlight/ + * shadow, port of toneShader.ts), then row-major 4x5 color matrix (translate + * column in 0..1 Skia space), JPEG-encode to dstPath. All native, off the + * JS/main threads. Stage order mirrors exportEngine: crop -> tone -> matrix. */ + processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, tone: ToneUniforms, quality: number): Promise; + /** P1 alias: matrix + crop, no tone pass. */ processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise; /** P0 probe helpers — removed before ship. */ ping(): Promise; materializeBenchAsset(assetName: string): Promise; - materializeBlackAsset(): Promise; + materializeSolidAsset(r: number, g: number, b: number): Promise; } export default requireNativeModule('RecipesCamExport') as RecipescamExportModule; diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts index 90c2ae4..592ee85 100644 --- a/src/dev/nativeBenchProbe.ts +++ b/src/dev/nativeBenchProbe.ts @@ -2,7 +2,12 @@ import RecipescamExport from '../../modules/recipescam-export'; import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils'; import { BaseFilter, ColorAdjustments } from '../types'; -const RUNS = 3; +const RUNS = 2; +const IDENTITY = (() => { + const m = new Array(20).fill(0); + m[0] = m[6] = m[12] = m[18] = 1; + return m; +})(); const NEUTRAL_ADJ: ColorAdjustments = { exposure: 0, @@ -23,43 +28,46 @@ const NEUTRAL_ADJ: ColorAdjustments = { }; /** - * P0/P1 probe harness (temporary — removed at P7). - * P0: native decode+encode on the bundled wallframe artwork (3117x4000). - * P1: native color matrix + crop path. Parity trick: classic-neg matrix applied - * to a pure-black image must yield translate*255 per channel in the native - * filter (Skia 0..1 space -> Android 0..255 space), i.e. ~(13, 5, 0); the - * center pixel is logged natively (RecipesCamExport tag) since release Hermes - * strips console.*. + * P0/P1/P2 probe harness (temporary — removed at P7). + * Native logs (RecipesCamExport tag) are the only visible channel: release + * Hermes strips console.*. Each gate prints its own centerPixel / timing lines. */ export async function nativeBenchProbe(): Promise { const m = RecipescamExport as any; try { const pong = await m.ping(); const src: string = await m.materializeBenchAsset('wallframe'); + // P1 parity: classic-neg matrix on black -> expect center ~(13,5,0). const matrix = applyExposureGain(getSkiaColorMatrix('classic-neg' as BaseFilter, NEUTRAL_ADJ), 0); - const black = await m.materializeBlackAsset(); + const black = await m.materializeSolidAsset(0, 0, 0); const blackOut = black.replace(/\.png$/, `-neg-${Date.now()}.jpg`); await m.processColorAsync(black, blackOut, matrix, null, 95); console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(',')); - // P0 runs (decode+encode) + P1 identity-matrix run on the real artwork. - const identity = new Array(20).fill(0); - identity[0] = identity[6] = identity[12] = identity[18] = 1; + // P2 tone parity on solid ramps (expected values computed from TONE_SKSL): + // white(255) hl=-1 -> 140; gray64 sh=+1 -> 75; white dr=1 -> 209; + // gray26 sh=+1 -> 104; gray26 hl=+1 sh=-1 -> 11. + const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [ + { rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' }, + { rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' }, + { rgb: [255, 255, 255], tone: { dr: 1, hl: 0, sh: 0 }, tag: 'white-dr1' }, + { rgb: [26, 26, 26], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray26-sh+1' }, + { rgb: [26, 26, 26], tone: { dr: 0, hl: 1, sh: -1 }, tag: 'gray26-hl+1-sh-1' }, + ]; + for (const g of toneGates) { + const solid = await m.materializeSolidAsset(g.rgb[0], g.rgb[1], g.rgb[2]); + const out = solid.replace(/\.png$/, `-tone-${Date.now()}.jpg`); + const t = await m.processPhotoAsync(solid, out, IDENTITY, null, g.tone.dr, g.tone.hl, g.tone.sh, 95); + console.error('[BENCH] toneGate ' + g.tag + ' totalMs=' + t.totalMs + ' toneMs=' + (t.toneMs ?? 0)); + } + + // Full-artwork run: provia + mild tone to sanity-check timing on 12MP. + const provia = applyExposureGain(getSkiaColorMatrix('provia' as BaseFilter, NEUTRAL_ADJ), 0); for (let i = 0; i < RUNS; i++) { - const t0 = Date.now(); - const dst = src.replace(/\.png$/, `-out-${Date.now()}-${i}.jpg`); - const timings = await m.decodeEncodeAsync(src, dst, 95); - console.error( - '[BENCH] run=' + i + ' jsRoundTripMs=' + (Date.now() - t0) + - ' decodeMs=' + timings.decodeMs + ' encodeWriteMs=' + timings.encodeWriteMs + - ' totalMs=' + timings.totalMs + ' pong=' + pong - ); - // P1 crop: center 2000x2000 window of the 3117x4000 artwork. - const crop = { x: Math.round((3117 - 2000) / 2), y: Math.round((4000 - 2000) / 2), width: 2000, height: 2000 }; - const colorDst = src.replace(/\.png$/, `-color-${Date.now()}-${i}.jpg`); - const ct = await m.processColorAsync(src, colorDst, identity, crop, 95); - console.error('[BENCH] colorRun=' + i + ' decodeMs=' + ct.decodeMs + ' colorMs=' + ct.colorMs + ' encodeWriteMs=' + ct.encodeWriteMs + ' totalMs=' + ct.totalMs); + const dst = src.replace(/\.png$/, `-full-${Date.now()}-${i}.jpg`); + const t = await m.processPhotoAsync(src, dst, provia, null, 0.33, 0.3, 0.3, 95); + console.error('[BENCH] fullRun=' + i + ' decodeMs=' + t.decodeMs + ' toneMs=' + (t.toneMs ?? 0) + ' colorMs=' + t.colorMs + ' encodeWriteMs=' + t.encodeWriteMs + ' totalMs=' + t.totalMs + ' pong=' + pong); } } catch (e) { console.error('[BENCH] failed', e);