P3 native cinema/clarity/denoise/grain (exportEngine port) verified on Xiaomi (gates pass)

This commit is contained in:
2026-09-09 15:58:15 +07:00
parent 71b9476901
commit 11635a0d33
4 changed files with 416 additions and 51 deletions
@@ -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<String, Any?>?, 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<String, Any?>?, adjust: Map<String, Any?> ->
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<String, Any?>?, 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<String, Any?>?,
toneDr: Double,
toneHl: Double,
toneSh: Double,
quality: Int,
adjust: Map<String, Any?>,
): Map<String, Int> {
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<String, Int>()
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<String, Int> {
val timings = mutableMapOf<String, Int>()
val t0 = System.nanoTime()
+32 -6
View File
@@ -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<DecodeEncodeTimings>;
/**
* 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<ColorTimings>;
/** P1 alias: matrix + crop, no tone pass. */
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise<ColorTimings>;
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;
materializeSolidAsset(r: number, g: number, b: number): Promise<string>;
materializeRampAsset(): Promise<string>;
materializeStepAsset(): Promise<string>;
}
export default requireNativeModule('RecipesCamExport') as RecipescamExportModule;