From 11635a0d33d527a7e749d2504b9a5ba2c7dc1ff2 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 15:58:15 +0700 Subject: [PATCH] P3 native cinema/clarity/denoise/grain (exportEngine port) verified on Xiaomi (gates pass) --- PLAN.md | 2 +- .../export/RecipescamExportModule.kt | 374 ++++++++++++++++-- modules/recipescam-export/index.ts | 38 +- src/dev/nativeBenchProbe.ts | 53 ++- 4 files changed, 416 insertions(+), 51 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2fc1fde..fa77ead 100644 --- a/PLAN.md +++ b/PLAN.md @@ -96,7 +96,7 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback. - [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) - [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 +- [x] P3 clarity/grain (native denoise/clarity/grain/cinema port: boxBlur padding fix + step gates; cinema gates summer-192→(211,209,201), winter-32→(82,85,88), summer-sat→(217,120,47) khớp node ±0; ramp/step blur gates denoise→130, clarity→131, mist→130/denoise-step→170, mist-step→142 PASS; grain structural OK; full 12MP 4.7-5.1s CPU = vượt D4, quyết GPU ở P6) - [ ] P4 frames/watermark - [ ] P5 hook export + flag - [ ] P6 parity/perf gate 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 f67cd17..2725fdf 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,8 +13,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import kotlin.math.abs +import kotlin.math.floor import kotlin.math.max import kotlin.math.roundToInt +import kotlin.math.sin private const val TAG = "RecipesCamExport" @@ -30,19 +33,26 @@ class RecipescamExportModule : Module() { decodeEncodeAsync(srcPath, dstPath, quality) } - // P1/P2 pipeline: decode -> optional crop -> optional tone (DR/hl/sh) -> - // color matrix -> JPEG encode, all native, all off the JS/main threads. + // P1-P3 pipeline mirroring exportEngine's filter order: + // decode -> crop -> tone (DR/hl/sh) -> cinema (seasonal grade) -> + // color matrix -> denoise blur -> clarity conv / mist blur -> grain -> + // JPEG encode, all native, 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) + // colorUtils.ts in JS). crop: {x,y,width,height}|null. adjust: map of the + // optional pipeline params (bundled — the expo Coroutine builder caps + // lambda arity at 8 positional args): + // toneDr/toneHl/toneSh (ToneUniforms from toneShader.ts; 0 = skip), + // cinema: 16 flat floats from cinemaShader.ts getCinemaUniforms(season) + // .flat (null/0 = skip), denoise/clarity/grain: ColorAdjustments + // 0..10 / -10..10 / 0..10, quality: JPEG 0..100 (default 95). + AsyncFunction("processPhotoAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map?, adjust: Map -> + processPipeline(srcPath, dstPath, matrix, crop, adjust) } // P1 alias (kept for the on-device matrix parity probe): matrix + crop, no - // tone pass. + // tone/cinema/enhance passes. AsyncFunction("processColorAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map?, quality: Int -> - processPipeline(srcPath, dstPath, matrix, crop, 0.0, 0.0, 0.0, quality) + processPipeline(srcPath, dstPath, matrix, crop, emptyMap()) } // P0 probe helpers — removed before ship. @@ -54,7 +64,7 @@ class RecipescamExportModule : Module() { // P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the // 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the // real file path back to JS, which then runs decodeEncodeAsync on it. This - // exercises the exact file-based path P1 will use. + // exercises the exact file-based path the pipeline uses. AsyncFunction("materializeBenchAsset") Coroutine { assetName: String -> val ctx = appContext.reactContext ?: error("react context lost") val resId = ctx.resources.getIdentifier(assetName, "drawable", ctx.packageName) @@ -89,6 +99,61 @@ class RecipescamExportModule : Module() { } srcFile.absolutePath } + + // P3 probe helper: materialize a 64x64 gray ramp PNG (row y = + // round(y*255/63)) — a gradient field makes the conv/blur passes + // non-trivial to gate (a conv over a solid is the identity). + AsyncFunction("materializeRampAsset") Coroutine { -> + val ctx = appContext.reactContext ?: error("react context lost") + val srcFile = File(ctx.cacheDir, "bench_ramp_gray.png") + withContext(Dispatchers.IO) { + if (!srcFile.exists()) { + val bmp = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val px = IntArray(64 * 64) + for (y in 0 until 64) { + val v = (y * 255f / 63f).roundToInt() + val row = y * 64 + for (x in 0 until 64) { + px[row + x] = (0xFF shl 24) or (v shl 16) or (v shl 8) or v + } + } + bmp.setPixels(px, 0, 64, 0, 0, 64, 64) + FileOutputStream(srcFile).use { fos -> + check(bmp.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" } + } + bmp.recycle() + } + Log.i(TAG, "ramp asset -> ${srcFile.absolutePath}") + } + srcFile.absolutePath + } + + // P3 probe helper: 64x64 vertical step (rows 0..31 black, 32..63 white) — + // blur of a step is a strong non-identity signal at the center row 32. + AsyncFunction("materializeStepAsset") Coroutine { -> + val ctx = appContext.reactContext ?: error("react context lost") + val srcFile = File(ctx.cacheDir, "bench_step_gray.png") + withContext(Dispatchers.IO) { + if (!srcFile.exists()) { + val bmp = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val px = IntArray(64 * 64) + for (y in 0 until 64) { + val v = if (y < 32) 0 else 255 + val row = y * 64 + for (x in 0 until 64) { + px[row + x] = (0xFF shl 24) or (v shl 16) or (v shl 8) or v + } + } + bmp.setPixels(px, 0, 64, 0, 0, 64, 64) + FileOutputStream(srcFile).use { fos -> + check(bmp.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" } + } + bmp.recycle() + } + Log.i(TAG, "step asset -> ${srcFile.absolutePath}") + } + srcFile.absolutePath + } } } @@ -97,16 +162,24 @@ private suspend fun processPipeline( dstPath: String, matrixList: List<*>, crop: Map?, - toneDr: Double, - toneHl: Double, - toneSh: Double, - quality: Int, + adjust: Map, ): Map { + val toneDr = (adjust["toneDr"] as? Number)?.toDouble() ?: 0.0 + val toneHl = (adjust["toneHl"] as? Number)?.toDouble() ?: 0.0 + val toneSh = (adjust["toneSh"] as? Number)?.toDouble() ?: 0.0 + val denoise = (adjust["denoise"] as? Number)?.toDouble() ?: 0.0 + val clarity = (adjust["clarity"] as? Number)?.toDouble() ?: 0.0 + val grain = (adjust["grain"] as? Number)?.toDouble() ?: 0.0 + val quality = (adjust["quality"] as? Number)?.toInt() ?: 95 + val cinemaList = adjust["cinema"] as? List<*> require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" } val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f } + val cinemaFlat = FloatArray(16) { i -> (cinemaList?.getOrNull(i) as? Number)?.toFloat() ?: 0f } + val cinemaActive = cinemaList != null && cinemaFlat[0] > 0f + val toneActive = toneDr != 0.0 || toneHl != 0.0 || toneSh != 0.0 val timings = mutableMapOf() val t0 = System.nanoTime() - Log.i(TAG, "processPhoto start src=$srcPath crop=$crop tone=($toneDr,$toneHl,$toneSh) thread=${Thread.currentThread().name}") + Log.i(TAG, "processPhoto start src=$srcPath crop=$crop tone=($toneDr,$toneHl,$toneSh) cinema=${if (cinemaActive) cinemaFlat[0] else 0} denoise=$denoise clarity=$clarity grain=$grain thread=${Thread.currentThread().name}") withContext(Dispatchers.IO) { val srcFile = File(srcPath) check(srcFile.exists()) { "source file missing: $srcPath" } @@ -128,24 +201,33 @@ private suspend fun processPipeline( Log.i(TAG, "cropped to ${bmp.width}x${bmp.height}") } - // 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}") + // Tone + cinema both mutate the decoded bitmap in place, and decodeFile/crop + // bitmaps are immutable -> promote to a mutable ARGB_8888 copy once. + if (toneActive || cinemaActive) { + bmp = ensureMutable(bmp) } - // 3. 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. Engine order: the tone + // shader samples the base image, cinema samples the tone output, then the + // color matrix filter applies on top. + val t2 = System.nanoTime() + if (toneActive) { + 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") + } + + // 3. Cinema seasonal grade. Port of CINEMA_SKSL from cinemaShader.ts, child + // = current pixels (tone output). Uniform 0 (strength) gates activity. + val t5 = System.nanoTime() + if (cinemaActive) { + applyCinema(bmp, cinemaFlat) + timings["cinemaMs"] = ((System.nanoTime() - t5) / 1_000_000).toInt() + Log.i(TAG, "cinema applied strength=${cinemaFlat[0]}") + } + + // 4. 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 t3 = System.nanoTime() @@ -161,13 +243,51 @@ private suspend fun processPipeline( Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})") bmp.recycle() - // 4. JPEG encode + write. + // 5. Denoise / clarity / grain (exportEngine #4 + #6), CPU passes on the + // matrix output. Engine chain: denoise blur -> clarity conv (or mist blur), + // then a grain Overlay rect on top. + val needEnhance = denoise > 0 || clarity != 0.0 + val needGrain = grain > 0 + if (needEnhance || needGrain) { + val t6 = System.nanoTime() + val wO = out.width + val hO = out.height + var p = IntArray(wO * hO) + out.getPixels(p, 0, wO, 0, 0, wO, hO) + if (denoise > 0) { + val sigma = (denoise / 10.0) * 0.6 // engine: max 0.6px gaussian + p = boxBlur(p, wO, hO, max(1, sigma.roundToInt())) + } + if (clarity > 0) { + val sharpAmount = ((clarity / 10.0) * 0.8).toFloat() + p = convClarity(p, wO, hO, sharpAmount) + } else if (clarity < 0) { + val mistSigma = (abs(clarity) / 10.0) * 4.0 // bloom/mist + p = boxBlur(p, wO, hO, max(1, mistSigma.roundToInt())) + } + if (needEnhance) timings["clarityMs"] = ((System.nanoTime() - t6) / 1_000_000).toInt() + val t7 = System.nanoTime() + if (grain > 0) { + val opacity = (grain / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20 + p = grainOverlay(p, wO, hO, opacity) + timings["grainMs"] = ((System.nanoTime() - t7) / 1_000_000).toInt() + Log.i(TAG, "grain applied opacity=$opacity") + } + out.setPixels(p, 0, wO, 0, 0, wO, hO) + } + + // 6. Final center pixel (post-enhance) — probe gates read this line; the + // matrix-stage log above only sees pre-enhance output. + val fCenter = out.getPixel(out.width / 2, out.height / 2) + Log.i(TAG, "finalPixel rgb=(${android.graphics.Color.red(fCenter)},${android.graphics.Color.green(fCenter)},${android.graphics.Color.blue(fCenter)})") + + // 7. 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() - 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}") + Log.i(TAG, "processPhoto done dst=$dstPath size=${out.width}x${out.height} decodeMs=${timings["decodeMs"]} toneMs=${timings["toneMs"] ?: 0} cinemaMs=${timings["cinemaMs"] ?: 0} colorMs=${timings["colorMs"]} clarityMs=${timings["clarityMs"] ?: 0} grainMs=${timings["grainMs"] ?: 0} encodeWriteMs=${timings["encodeWriteMs"]}") out.recycle() } timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt() @@ -175,6 +295,13 @@ private suspend fun processPipeline( return timings } +private fun ensureMutable(bmp: Bitmap): Bitmap { + if (bmp.isMutable) return bmp + val m = bmp.copy(Bitmap.Config.ARGB_8888, true) + if (m !== bmp) bmp.recycle() + return m +} + private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) { val w = bmp.width val h = bmp.height @@ -189,7 +316,6 @@ private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) { 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) { @@ -216,11 +342,193 @@ private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) { bmp.setPixels(px, 0, w, 0, 0, w, h) } +private fun applyCinema(bmp: Bitmap, c: FloatArray) { + val w = bmp.width + val h = bmp.height + val px = IntArray(w * h) + bmp.getPixels(px, 0, w, 0, 0, w, h) + val strength = c[0] + val contrast = c[1] + val saturation = c[2] + val warmR = c[4]; val warmG = c[5]; val warmB = c[6]; val warmAmt = c[7] + val coolR = c[8]; val coolG = c[9]; val coolB = c[10]; val coolAmt = c[11] + val fogR = c[12]; val fogG = c[13]; val fogB = c[14]; val fogAmt = c[15] + val n = px.size + for (i in 0 until n) { + val col = px[i] + val r0 = ((col shr 16) and 0xFF) / 255f + val g0 = ((col shr 8) and 0xFF) / 255f + val b0 = (col and 0xFF) / 255f + val t = (0.2126f * r0 + 0.7152f * g0 + 0.0722f * b0).coerceIn(0f, 1f) + // Filmic-ish gentle contrast on the luma (keeps hue): port of CINEMA_SKSL + // `r = mix(r0, clamp((r0-0.5)*(1.0+contrast*strength)+0.5), strength)`. + val k = 1f + contrast * strength + val cr = ((r0 - 0.5f) * k + 0.5f).coerceIn(0f, 1f) + val cg = ((g0 - 0.5f) * k + 0.5f).coerceIn(0f, 1f) + val cb = ((b0 - 0.5f) * k + 0.5f).coerceIn(0f, 1f) + var r = r0 + (cr - r0) * strength + var g = g0 + (cg - g0) * strength + var b = b0 + (cb - b0) * strength + // Saturation: CINEMA_SKSL `r = mix(vec3(l), r, mix(1.0, saturation, strength))`. + val l = 0.2126f * r + 0.7152f * g + 0.0722f * b + val satM = 1f + (saturation - 1f) * strength + r = l + (r - l) * satM + g = l + (g - l) * satM + b = l + (b - l) * satM + // Season tints follow the existing light: warm on lit, cool in shade. + val lit = smoothstep(0.30f, 1.0f, t) + val shad = 1f - smoothstep(0.0f, 0.45f, t) + val wMul = lit * warmAmt * strength + r *= 1f + (warmR - 1f) * wMul + g *= 1f + (warmG - 1f) * wMul + b *= 1f + (warmB - 1f) * wMul + val cMul = shad * coolAmt * strength + r *= 1f + (coolR - 1f) * cMul + g *= 1f + (coolG - 1f) * cMul + b *= 1f + (coolB - 1f) * cMul + // Soft sun glow at the brightest points (halation). + val glow = smoothstep(0.65f, 1.0f, t) + val glowK = glow * warmAmt * 0.45f * strength + r += warmR * glowK + g += warmG * glowK + b += warmB * glowK + // Atmospheric haze/fog veil (lightest shadow side => misty air). + val fogK = fogAmt * strength * (0.3f + 0.7f * shad) + r += (fogR - r) * fogK + g += (fogG - g) * fogK + b += (fogB - b) * fogK + val alpha = (col ushr 24) and 0xFF + val nr = (r.coerceIn(0f, 1f) * 255f).roundToInt() + val ng = (g.coerceIn(0f, 1f) * 255f).roundToInt() + val nb = (b.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) } +// Separable box blur with TileMode.Clamp (edge replicate), integer math: +// out = round(sum over (2r+1)^2 samples / (2r+1)^2), alpha untouched. +// Implemented with explicit edge padding (r replicated samples on each side) +// so every output pixel averages exactly (2r+1)^2 taps — a clamp-read sliding +// window without padding miscounts border taps (the window grows to 2r+2 once +// the phantom left-edge tap is skipped). +private fun boxBlur(px: IntArray, w: Int, h: Int, r: Int): IntArray { + val size = 2 * r + 1 + val size2 = size * size + val half = size2 / 2 + val n = w * h + val rowPad = w + 2 * r + val colPad = h + 2 * r + val padded = IntArray(rowPad) + val col = IntArray(colPad) + val hor = IntArray(n) + val out = IntArray(n) + for (sh in intArrayOf(16, 8, 0)) { + for (y in 0 until h) { + val yw = y * w + val first = (px[yw] shr sh) and 0xFF + val last = (px[yw + w - 1] shr sh) and 0xFF + var i = 0 + while (i < r) padded[i++] = first + for (x in 0 until w) padded[r + x] = (px[yw + x] shr sh) and 0xFF + i = r + w + while (i < rowPad) padded[i++] = last + var sum = 0 + for (k in 0 until size) sum += padded[k] + for (x in 0 until w) { + hor[yw + x] = sum + if (x + size < rowPad) sum += padded[x + size] - padded[x] + } + } + for (x in 0 until w) { + val top = hor[x] + val bottom = hor[(h - 1) * w + x] + var k = 0 + while (k < r) col[k++] = top + for (y in 0 until h) col[r + y] = hor[y * w + x] + k = r + h + while (k < colPad) col[k++] = bottom + var sum = 0 + for (k2 in 0 until size) sum += col[k2] + for (y in 0 until h) { + val idx = y * w + x + out[idx] = out[idx] or (((sum + half) / size2) shl sh) + if (y + size < colPad) sum += col[y + size] - col[y] + } + } + } + for (i in 0 until n) { + out[i] = out[i] or (px[i] and -0x1000000) // keep alpha + } + return out +} + +// Clarity sharpening — the engine's 3x3 convolution kernel +// [0,-a,0; -a,1+4a,-a; 0,-a,0] is separable into center + a * (4c - N-S-E-W) +// laplacian form, so only 4 neighbor taps are needed (identical result). +// TileMode.Clamp edges, bias 0, divisor 1, alpha untouched (photos opaque). +private fun convClarity(px: IntArray, w: Int, h: Int, a: Float): IntArray { + val n = w * h + val out = IntArray(n) + for (y in 0 until h) { + val yRow = y * w + val yUp = (if (y == 0) 0 else y - 1) * w + val yDn = (if (y == h - 1) y else y + 1) * w + for (x in 0 until w) { + val xL = if (x == 0) 0 else x - 1 + val xR = if (x == w - 1) x else x + 1 + val i0 = yRow + x + val cpx = px[i0] + val iN = yUp + x + val iS = yDn + x + val iE = yRow + xR + val iW = yRow + xL + var acc = 0 + for (sh in intArrayOf(16, 8, 0)) { + val c = ((cpx shr sh) and 0xFF) / 255f + val v = (c + a * (4f * c - (((px[iN] shr sh) and 0xFF) / 255f) - (((px[iS] shr sh) and 0xFF) / 255f) - (((px[iE] shr sh) and 0xFF) / 255f) - (((px[iW] shr sh) and 0xFF) / 255f))).coerceIn(0f, 1f) + acc = acc or ((v * 255f).roundToInt() shl sh) + } + out[i0] = acc or (cpx and -0x1000000) + } + } + return out +} + +// Grain: per-pixel hash noise (exportEngine #6 noise shader +// fract(sin(dot(pos,vec2(12.9898,78.233)))*43758.5453)) blended with +// BlendMode.Overlay at opacity alpha. Overlay = W3C backdrop-controlled: +// b<0.5 ? 2*b*s : 1-2*(1-b)*(1-s), then source-alpha composite. +private fun grainOverlay(px: IntArray, w: Int, h: Int, alpha: Float): IntArray { + val n = w * h + val out = IntArray(n) + val oneMinusAlpha = 1f - alpha + for (y in 0 until h) { + val yw = y * w + for (x in 0 until w) { + val i = yw + x + val c = px[i] + val d = x * 12.9898f + y * 78.233f + val noise = sin(d) * 43758.5453f + val s = noise - floor(noise) + var acc = 0 + for (sh in intArrayOf(16, 8, 0)) { + val b = ((c shr sh) and 0xFF) / 255f + val o = if (b < 0.5f) 2f * b * s else 1f - 2f * (1f - b) * (1f - s) + val v = (o * alpha + b * oneMinusAlpha).coerceIn(0f, 1f) + acc = acc or ((v * 255f).roundToInt() shl sh) + } + out[i] = acc or (c and -0x1000000) + } + } + return out +} + 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 8e93d3f..1c9d014 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -9,7 +9,10 @@ export interface DecodeEncodeTimings { export interface ColorTimings { decodeMs: number; toneMs?: number; + cinemaMs?: number; colorMs: number; + clarityMs?: number; + grainMs?: number; encodeWriteMs: number; totalMs: number; } @@ -29,22 +32,45 @@ export interface ToneUniforms { sh: number; } +/** Optional pipeline params passed as a map (native Coroutine arity cap = 8). */ +export interface PhotoAdjust { + /** ToneUniforms from toneShader.ts; default 0/0/0 = skip tone pass. */ + toneDr?: number; + toneHl?: number; + toneSh?: number; + /** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */ + cinema?: number[] | null; + /** ColorAdjustments.denoise 0..10 (0 = skip). */ + denoise?: number; + /** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */ + clarity?: number; + /** ColorAdjustments.grain 0..10 (0 = skip). */ + grain?: number; + /** JPEG quality 0..100; default 95. */ + quality?: 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, 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. + * Native pipeline mirroring exportEngine.ts filter order — decode, optional + * center-largest crop, optional tone pass (TONE_SKSL port), optional cinema + * seasonal grade (CINEMA_SKSL port), row-major 4x5 color matrix (translate + * column in 0..1 Skia space), optional denoise blur / clarity conv / mist + * blur / grain Overlay, then JPEG-encode to dstPath. All native, off the + * JS/main threads. Stage order mirrors exportEngine: crop -> tone -> cinema + * -> matrix -> denoise/clarity -> grain -> encode. */ - processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, tone: ToneUniforms, quality: number): Promise; - /** P1 alias: matrix + crop, no tone pass. */ + processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise; + /** P1 alias: matrix + crop, no tone/cinema/enhance passes. */ 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; materializeSolidAsset(r: number, g: number, b: number): Promise; + materializeRampAsset(): Promise; + materializeStepAsset(): Promise; } export default requireNativeModule('RecipesCamExport') as RecipescamExportModule; diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts index 592ee85..56ef1b8 100644 --- a/src/dev/nativeBenchProbe.ts +++ b/src/dev/nativeBenchProbe.ts @@ -1,5 +1,6 @@ -import RecipescamExport from '../../modules/recipescam-export'; +import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export'; import { applyExposureGain, getSkiaColorMatrix } from '../utils/colorUtils'; +import { getCinemaUniforms } from '../utils/cinemaShader'; import { BaseFilter, ColorAdjustments } from '../types'; const RUNS = 2; @@ -28,14 +29,20 @@ const NEUTRAL_ADJ: ColorAdjustments = { }; /** - * 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. + * P0-P3 probe harness (temporary — removed at P7). Reads the native + * RecipesCamExport "centerPixel"/"finalPixel" log lines (probe gates compare + * those against the node reference sim). */ export async function nativeBenchProbe(): Promise { const m = RecipescamExport as any; try { const pong = await m.ping(); + const run = async (tag: string, src: string, o: PhotoAdjust & { matrix?: number[] } = {}) => { + const { matrix, ...adj } = o; + const dst = src.replace(/\.png$/, `-${tag}-${Date.now()}.jpg`); + const t = await m.processPhotoAsync(src, dst, matrix ?? IDENTITY, null, adj); + console.error(`[BENCH] ${tag} totalMs=${t.totalMs} decodeMs=${t.decodeMs} toneMs=${t.toneMs ?? 0} cinemaMs=${t.cinemaMs ?? 0} colorMs=${t.colorMs} clarityMs=${t.clarityMs ?? 0} grainMs=${t.grainMs ?? 0} encodeWriteMs=${t.encodeWriteMs}`); + }; const src: string = await m.materializeBenchAsset('wallframe'); // P1 parity: classic-neg matrix on black -> expect center ~(13,5,0). @@ -57,18 +64,42 @@ export async function nativeBenchProbe(): Promise { ]; 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)); + await run(`tone-${g.tag}`, solid, { toneDr: g.tone.dr, toneHl: g.tone.hl, toneSh: g.tone.sh }); } - // Full-artwork run: provia + mild tone to sanity-check timing on 12MP. + // P3 cinema parity (CINEMA_SKSL): lit+warm+glow path, shade+cool+fog path, + // and a saturated pixel to exercise the saturation mix. + const summer = getCinemaUniforms('summer')!.flat; + const winter = getCinemaUniforms('winter')!.flat; + const s192 = await m.materializeSolidAsset(192, 192, 192); + await run('cinema-summer-192', s192, { cinema: summer }); + const s32 = await m.materializeSolidAsset(32, 32, 32); + await run('cinema-winter-32', s32, { cinema: winter }); + const sat = await m.materializeSolidAsset(200, 120, 60); + await run('cinema-summer-sat', sat, { cinema: summer }); + + // P3 enhance gates on the gray ramp (gradient makes blur/conv non-trivial). + const ramp = await m.materializeRampAsset(); + await run('denoise10-ramp', ramp, { denoise: 10 }); + await run('clarity10-ramp', ramp, { clarity: 10 }); + await run('mist-ramp', ramp, { clarity: -10 }); + // Step gates: rows 0..31=0, 32..63=255. A ramp is invariant under box + // blur, so the step makes heavy blur non-trivial (expected center row32: + // denoise r=1 -> 170, mist r=4 -> 142 per the padded-boxBlur reference). + const step = await m.materializeStepAsset(); + await run('denoise10-step', step, { denoise: 10 }); + await run('mist-step', step, { clarity: -10 }); + // Grain is structurally-gated only (float32 hash noise vs node doubles + // differ) — the run must complete and the center must stay plausible. + const gray100 = await m.materializeSolidAsset(100, 100, 100); + await run('grain10-100', gray100, { grain: 10 }); + + // Full-artwork run: provia + tone + cinema + all enhance passes, 12MP. const provia = applyExposureGain(getSkiaColorMatrix('provia' as BaseFilter, NEUTRAL_ADJ), 0); for (let i = 0; i < RUNS; i++) { - 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); + await run(`fullRun-${i}`, src, { matrix: provia, toneDr: 0.33, toneHl: 0.3, toneSh: 0.3, cinema: summer, denoise: 10, clarity: 10, grain: 10 }); } + console.error('[BENCH] done pong=' + pong); } catch (e) { console.error('[BENCH] failed', e); }