P3 native cinema/clarity/denoise/grain (exportEngine port) verified on Xiaomi (gates pass)
This commit is contained in:
+341
-33
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user