P4 native frames (classic/polaroid/wallframe) + GPS watermark verified on Xiaomi (sample gates pass)

This commit is contained in:
2026-09-09 16:34:24 +07:00
parent 11635a0d33
commit 25e4f21917
4 changed files with 380 additions and 10 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ Exit: user QA đạt → mặc định native; legacy chỉ fallback.
- [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)
- [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
- [x] P4 frames/watermark
- [ ] P5 hook export + flag
- [ ] P6 parity/perf gate
- [ ] P7 dọn + ship
@@ -1,10 +1,15 @@
package com.locphamtran.recipescamera.export
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.Base64
import android.util.Log
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
@@ -13,14 +18,45 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
private const val TAG = "RecipesCamExport"
// ---- P4 frame geometry — ported verbatim from src/utils/frameUtils.ts ----
// (POLAROID_CARD / POLAROID_WIN_W / WALLFRAME_WIN). All fractions relative to
// the card/frame width so the layout scales to any pixel size.
private const val POL_SIDE = (88.0 - 79.0) / 2.0 / 88.0 // horizontal + top margin ≈ 0.05114
private const val POL_DECK = (107.0 - 79.0 - (88.0 - 79.0) / 2.0) / 88.0 // bottom deck ≈ 0.26705
private const val POL_WIN_W = 1.0 - 2.0 * POL_SIDE // image window width fraction ≈ 0.89773
private const val WF_W = 3117 // rotated wallframe display width (= PNG height)
private const val WF_H = 4000 // rotated wallframe display height (= PNG width)
private const val WF_WIN_LEFT = 676.0 / 3117.0
private const val WF_WIN_TOP = 675.0 / 4000.0
private const val WF_WIN_W = 1766.0 / 3117.0
private const val WF_WIN_H = 2647.0 / 4000.0
// Export colors (exportEngine #7/#8).
private const val CARD_BG = 0xFFFAF9F6.toInt() // polaroid off-white card
private const val SEAM_COLOR = 0xFFE5E5E5.toInt()
private const val AMBER = 0xFFF59E0B.toInt()
private data class GeoStamp(val locality: String, val timestampMs: Long)
// Framed-result wrapper: the output bitmap plus the photo window rect (used to
// anchor the GPS stamp inside polaroid/wallframe windows), engine #7/#8.
private class FramedResult(val bmp: Bitmap, val window: RectF?)
// Font temp files are tiny and reused across exports — cache the Typefaces.
private val typefaceCache = HashMap<String, Typeface>()
class RecipescamExportModule : Module() {
override fun definition() = ModuleDefinition {
Name("RecipesCamExport")
@@ -46,13 +82,15 @@ class RecipescamExportModule : Module() {
// .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)
val ctx = appContext.reactContext ?: error("react context lost")
processPipeline(srcPath, dstPath, matrix, crop, adjust, ctx)
}
// P1 alias (kept for the on-device matrix parity probe): matrix + crop, no
// tone/cinema/enhance passes.
AsyncFunction("processColorAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map<String, Any?>?, quality: Int ->
processPipeline(srcPath, dstPath, matrix, crop, emptyMap())
val ctx = appContext.reactContext ?: error("react context lost")
processPipeline(srcPath, dstPath, matrix, crop, emptyMap(), ctx)
}
// P0 probe helpers — removed before ship.
@@ -100,6 +138,25 @@ class RecipescamExportModule : Module() {
srcFile.absolutePath
}
// P4 probe helper: solid RGB PNG at arbitrary size (geo-stamp gate needs a
// 400px base so the 3.2%-of-width text is large enough to render).
AsyncFunction("materializeSolidSized") Coroutine { w: Int, h: Int, r: Int, g: Int, b: Int ->
val ctx = appContext.reactContext ?: error("react context lost")
val srcFile = File(ctx.cacheDir, "bench_solid_${w}x${h}_${r}_${g}_${b}.png")
withContext(Dispatchers.IO) {
if (!srcFile.exists()) {
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
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, "solid sized asset -> ${srcFile.absolutePath}")
}
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).
@@ -163,6 +220,7 @@ private suspend fun processPipeline(
matrixList: List<*>,
crop: Map<String, Any?>?,
adjust: Map<String, Any?>,
ctx: Context? = null,
): Map<String, Int> {
val toneDr = (adjust["toneDr"] as? Number)?.toDouble() ?: 0.0
val toneHl = (adjust["toneHl"] as? Number)?.toDouble() ?: 0.0
@@ -171,15 +229,28 @@ private suspend fun processPipeline(
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 frameId = (adjust["frameId"] as? String) ?: "none"
val frameArtB64 = adjust["frameArtB64"] as? String
val fontB64 = adjust["fontB64"] as? String
val iconB64 = adjust["iconB64"] as? String
val png = adjust["png"] == true
val geoMap = adjust["geo"] as? Map<String, Any?>
val probeSamples = adjust["probeSamples"] as? List<*>
val probeStampDiff = adjust["probeStampDiff"] == true
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 geo = if (geoMap != null && ctx != null) {
val locality = geoMap["locality"] as? String
val ts = (geoMap["timestamp"] as? Number)?.toLong()
if (locality != null && ts != null) GeoStamp(locality, ts) else null
} else null
val timings = mutableMapOf<String, Int>()
val t0 = System.nanoTime()
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}")
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 frame=$frameId geo=${geo != null} thread=${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
val srcFile = File(srcPath)
check(srcFile.exists()) { "source file missing: $srcPath" }
@@ -281,14 +352,56 @@ private suspend fun processPipeline(
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.
// 6b. Frame (exportEngine #7) + GPS watermark (#8). RETRO POLAROID / WALL
// FRAME grow to a bigger output bitmap (the export IS the card/frame);
// classic/cinematic borders draw over the photo canvas. The stamp is drawn
// last on the final output canvas so text sits on the engine layout.
var render: Bitmap = out
if (frameId != "none" || geo != null) {
val t8 = System.nanoTime()
val fr = applyFrame(out, frameId, ctx, frameArtB64)
val framed = fr.bmp
if (framed !== out) {
out.recycle()
render = framed
}
if (geo != null && ctx != null) {
val (diff, amber, white) = stampGeo(ctx, render, fr.window, geo, fontB64, iconB64, probeStampDiff)
Log.i(TAG, "stamp drawn locality=${geo.locality} stampPixelDiff=$diff stampAmber=$amber stampWhite=$white")
}
timings["frameMs"] = ((System.nanoTime() - t8) / 1_000_000).toInt()
}
// Probe-only sample points (P7 cleanup): read raw pixels of the final
// render at requested coords — the P4 frame/watermark gates assert
// geometry from these lines.
if (probeSamples != null) {
for (s in probeSamples) {
val m = s as? Map<*, *> ?: continue
val sx = (m["x"] as? Number)?.toInt() ?: continue
val sy = (m["y"] as? Number)?.toInt() ?: continue
if (sx < 0 || sy < 0 || sx >= render.width || sy >= render.height) {
Log.i(TAG, "samplePixel ($sx,$sy) out-of-bounds size=${render.width}x${render.height}")
continue
}
val p = render.getPixel(sx, sy)
Log.i(TAG, "samplePixel ($sx,$sy) rgb=(${android.graphics.Color.red(p)},${android.graphics.Color.green(p)},${android.graphics.Color.blue(p)})")
}
}
// 7. Encode + write (JPEG by default; PNG when png=true — probe gates use
// PNG so lossless pixels are compared exactly, production keeps JPEG).
val t4 = System.nanoTime()
FileOutputStream(dstPath).use { fos ->
check(out.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" }
if (png) {
check(render.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" }
} else {
check(render.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} cinemaMs=${timings["cinemaMs"] ?: 0} colorMs=${timings["colorMs"]} clarityMs=${timings["clarityMs"] ?: 0} grainMs=${timings["grainMs"] ?: 0} encodeWriteMs=${timings["encodeWriteMs"]}")
out.recycle()
Log.i(TAG, "processPhoto done dst=$dstPath size=${render.width}x${render.height} decodeMs=${timings["decodeMs"]} toneMs=${timings["toneMs"] ?: 0} cinemaMs=${timings["cinemaMs"] ?: 0} colorMs=${timings["colorMs"]} clarityMs=${timings["clarityMs"] ?: 0} grainMs=${timings["grainMs"] ?: 0} frameMs=${timings["frameMs"] ?: 0} encodeWriteMs=${timings["encodeWriteMs"]}")
render.recycle()
}
timings["totalMs"] = ((System.nanoTime() - t0) / 1_000_000).toInt()
Log.i(TAG, "processPhoto totalMs=${timings["totalMs"]}")
@@ -554,3 +667,218 @@ private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality:
Log.i(TAG, "totalMs=${timings["totalMs"]}")
return timings
}
// JS Math.round on positive doubles: floor(x + 0.5). (Kotlin roundToInt ties to
// even, which would drift from the engine's layout by a pixel on .5 cases.)
private fun engineRound(x: Double): Int = floor(x + 0.5).toInt()
// ---- P4 frames + GPS watermark (exportEngine #7/#8) ----
private fun fill(c: Canvas, x: Float, y: Float, w: Float, h: Float, color: Int) {
val p = Paint().apply { this.color = color; style = Paint.Style.FILL }
c.drawRect(x, y, x + w, y + h, p)
}
private fun applyFrame(photo: Bitmap, frameId: String, ctx: Context?, frameArtB64: String?): FramedResult {
val w = photo.width
val h = photo.height
when (frameId) {
// drawFrameOnCanvas(frameUtils.ts): 5% clean white border.
"classic-white" -> {
val c = Canvas(photo)
val bw = min(w, h) * 0.05f
fill(c, 0f, 0f, w.toFloat(), bw, Color.WHITE)
fill(c, 0f, h - bw, w.toFloat(), bw, Color.WHITE)
fill(c, 0f, 0f, bw, h.toFloat(), Color.WHITE)
fill(c, w - bw, 0f, bw, h.toFloat(), Color.WHITE)
return FramedResult(photo, null)
}
// Legacy 12% letterbox bars (drawFrameOnCanvas legacy branch).
"cinematic" -> {
val c = Canvas(photo)
val bar = h * 0.12f
fill(c, 0f, 0f, w.toFloat(), bar, Color.BLACK)
fill(c, 0f, h - bar, w.toFloat(), bar, Color.BLACK)
return FramedResult(photo, null)
}
// Export IS the off-white card; photo composited uncropped into the window
// (polaroidLayout 'contain' with the canvas at natural card proportions).
"polaroid" -> {
val outW = w
val outH = engineRound(w * (POL_SIDE + POL_DECK) + h * POL_WIN_W)
val card = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888)
val c = Canvas(card)
c.drawColor(CARD_BG)
val pa = w.toDouble() / h.toDouble()
val nat = POL_SIDE + POL_DECK + POL_WIN_W / pa
var cardW = min(outW.toDouble(), outH / nat)
var cardH = cardW * nat
val cx = (outW - cardW) / 2.0
val cy = (outH - cardH) / 2.0
val top = POL_SIDE * cardW
var winW = POL_WIN_W * cardW
var winH = winW / pa
val maxWinH = cardH - top - POL_DECK * cardW
if (winH > maxWinH) {
winH = maxWinH
winW = winH * pa
}
val winX = cx + (cardW - winW) / 2.0
val winY = cy + top
val p = Paint().apply { isFilterBitmap = true }
c.drawBitmap(photo, null, RectF(winX.toFloat(), winY.toFloat(), (winX + winW).toFloat(), (winY + winH).toFloat()), p)
// Thin seam separating the image window from the card (1px stroke at
// +0.5 so the stroke covers exactly one pixel row/col).
val seam = Paint().apply { color = SEAM_COLOR; style = Paint.Style.STROKE; strokeWidth = 1f }
c.drawRect((winX + 0.5).toFloat(), (winY + 0.5).toFloat(), (winX + winW - 0.5).toFloat(), (winY + winH - 0.5).toFloat(), seam)
return FramedResult(card, RectF(winX.toFloat(), winY.toFloat(), (winX + winW).toFloat(), (winY + winH).toFloat()))
}
// Export IS the rotated frame PNG at native portrait size; photo
// cover-cropped (center) into the fixed window, artwork over it (its
// transparent window reveals the photo), same affine as wallframeLayout.
"wallframe" -> {
val winX = WF_WIN_LEFT * WF_W
val winY = WF_WIN_TOP * WF_H
val winW = WF_WIN_W * WF_W
val winH = WF_WIN_H * WF_H
val card = Bitmap.createBitmap(WF_W, WF_H, Bitmap.Config.ARGB_8888)
val c = Canvas(card)
// Photo cover-crop. Uniform scale: scale = max(winW/pw, winH/ph), src
// rect centered on the photo (engine drawImageRect with linear filter).
val pw = w.toDouble()
val ph = h.toDouble()
val scale = max(winW / pw, winH / ph)
val srcW = winW / scale
val srcH = winH / scale
val srcX = (pw - srcW) / 2.0
val srcY = (ph - srcH) / 2.0
c.save()
c.clipRect(winX.toFloat(), winY.toFloat(), (winX + winW).toFloat(), (winY + winH).toFloat())
val p = Paint().apply { isFilterBitmap = true }
c.translate(winX.toFloat(), winY.toFloat())
c.scale(scale.toFloat(), scale.toFloat())
c.translate((-srcX).toFloat(), (-srcY).toFloat())
c.drawBitmap(photo, 0f, 0f, p)
c.restore()
// Artwork (4000x3117 landscape PNG) rotated 90° CW onto the portrait
// canvas — translate(WF_W,0); rotate(90) around origin, exact integer
// rotation so no resampling happens. Base64 string (JS asset bridge) or,
// when omitted, the bundled drawable directly — embedded metro assets
// expose no readable file URI to JS, so native resource lookup is the
// robust path for the same PNG.
val art = if (frameArtB64 != null) {
val bytes = Base64.decode(frameArtB64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: error("wallframe art decode failed")
} else {
checkNotNull(ctx) { "wallframe needs a bundled drawable or frameArtB64" }
val resId = ctx.resources.getIdentifier("wallframe", "drawable", ctx.packageName)
check(resId != 0) { "drawable wallframe not bundled" }
BitmapFactory.decodeStream(ctx.resources.openRawResource(resId)) ?: error("wallframe art decode failed")
}
check(art.width == 4000 && art.height == 3117) { "wallframe art must be 4000x3117, got ${art.width}x${art.height}" }
c.save()
c.translate(WF_W.toFloat(), 0f)
c.rotate(90f)
c.drawBitmap(art, 0f, 0f, null)
c.restore()
art.recycle()
return FramedResult(card, RectF(winX.toFloat(), winY.toFloat(), (winX + winW).toFloat(), (winY + winH).toFloat()))
}
else -> return FramedResult(photo, null) // "none"
}
}
// Typeface from base64 TTF, or from the bundled raw resource (metro .ttf assets
// land in res/raw as assets_<name-without-dash/dot> and are not FileSystem-
// readable from JS). Both write the tiny font to cacheDir once, then load.
private fun geoTypeface(ctx: Context, b64: String, name: String): Typeface {
synchronized(typefaceCache) {
typefaceCache[name]?.let { return it }
val f = File(ctx.cacheDir, "geo_$name.ttf")
if (!f.exists()) {
val bytes = Base64.decode(b64, Base64.DEFAULT)
FileOutputStream(f).use { it.write(bytes) }
}
val tf = Typeface.createFromFile(f)
typefaceCache[name] = tf
return tf
}
}
private fun geoTypefaceRes(ctx: Context, resName: String): Typeface {
synchronized(typefaceCache) {
typefaceCache[resName]?.let { return it }
val f = File(ctx.cacheDir, "geo_$resName.ttf")
if (!f.exists()) {
val resId = ctx.resources.getIdentifier(resName, "raw", ctx.packageName)
check(resId != 0) { "raw $resName not bundled" }
ctx.resources.openRawResource(resId).use { input ->
FileOutputStream(f).use { out -> input.copyTo(out) }
}
}
val tf = Typeface.createFromFile(f)
typefaceCache[resName] = tf
return tf
}
}
// GPS watermark (engine #8): 📍 + locality on the top row, 📷 + timestamp on
// the bottom, bottom-left inside the photo window for framed exports (polaroid
// / wallframe) else bottom-left of the image clear of the edge. Returns pixel
// stats when probeStampDiff is set: (# changed pixels, # amber-ish, # white)
// — probe-only evidence the text/emoji actually rendered.
private fun stampGeo(
ctx: Context,
bmp: Bitmap,
win: RectF?,
geo: GeoStamp,
fontB64: String?,
iconB64: String?,
stats: Boolean,
): Triple<Int, Int, Int> {
val w = bmp.width
val h = bmp.height
val fontSize = engineRound(w * 0.032)
val textPaint = Paint().apply {
isAntiAlias = false
color = AMBER
textSize = fontSize.toFloat()
typeface = if (fontB64 != null) geoTypeface(ctx, fontB64, "cousine") else geoTypefaceRes(ctx, "assets_cousineregular")
}
val iconPaint = Paint().apply {
isAntiAlias = false
color = Color.WHITE
textSize = fontSize.toFloat()
typeface = if (iconB64 != null) geoTypeface(ctx, iconB64, "notoemoji") else geoTypefaceRes(ctx, "assets_notoemojigps")
}
val gap = engineRound(fontSize * 1.6)
val baseX = if (win != null) win.left + win.width() * 0.05f else fontSize * 1.2f
val baseY = if (win != null) {
win.top + win.height() - engineRound(fontSize * 1.4)
} else {
(h - fontSize * 1.5).toFloat()
}
val before = if (stats) IntArray(w * h).also { bmp.getPixels(it, 0, w, 0, 0, w, h) } else null
val canvas = Canvas(bmp)
val stampFmt = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US)
val tsStr = stampFmt.format(Date(geo.timestampMs))
canvas.drawText("\uD83D\uDCCD", baseX, baseY - fontSize * 1.4f, iconPaint)
canvas.drawText(geo.locality, baseX + gap, baseY - fontSize * 1.4f, textPaint)
canvas.drawText("\uD83D\uDCF7", baseX, baseY, iconPaint)
canvas.drawText(tsStr, baseX + gap, baseY, textPaint)
if (!stats) return Triple(0, 0, 0)
val after = IntArray(w * h)
bmp.getPixels(after, 0, w, 0, 0, w, h)
var diff = 0
var amber = 0
var white = 0
for (i in after.indices) {
if (after[i] == before!![i]) continue
diff++
val r = Color.red(after[i])
val g = Color.green(after[i])
val b = Color.blue(after[i])
if (abs(r - 245) <= 40 && abs(g - 158) <= 40 && abs(b - 11) <= 60) amber++
if (r >= 235 && g >= 235 && b >= 235) white++
}
return Triple(diff, amber, white)
}
+20 -1
View File
@@ -13,6 +13,7 @@ export interface ColorTimings {
colorMs: number;
clarityMs?: number;
grainMs?: number;
frameMs?: number;
encodeWriteMs: number;
totalMs: number;
}
@@ -48,6 +49,22 @@ export interface PhotoAdjust {
grain?: number;
/** JPEG quality 0..100; default 95. */
quality?: number;
/** Photo frame preset applied before encode, mirroring frameUtils.ts. Default 'none'. */
frameId?: 'none' | 'classic-white' | 'cinematic' | 'polaroid' | 'wallframe';
/** Base64 PNG art (wallframe only, 4000x3117). Decoded per call. */
frameArtB64?: string;
/** Base64 TTF body font (geo stamp only). Cached after first decode. */
fontB64?: string;
/** Base64 TTF emoji font (geo stamp only). Cached after first decode. */
iconB64?: string;
/** Optional GPS stamp {locality, timestamp}; null/omitted = skip. */
geo?: { locality: string; timestamp: number } | null;
/** Encode PNG instead of JPEG. Probe-only. */
png?: boolean;
/** Probe-only: sample these photo-space pixel coords after frame pass, before encode; logs via samplePixel. */
probeSamples?: Array<{ x: number; y: number }>;
/** Probe-only: log per-row stamp diff/amber/white metrics (see samplePixel) and return them in ColorTimings-adjacent logs. */
probeStampDiff?: boolean;
}
export interface RecipescamExportModule {
@@ -60,7 +77,9 @@ export interface RecipescamExportModule {
* 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.
* -> matrix -> denoise/clarity -> grain -> frame (classic/cinematic/
* polaroid/wallframe) -> GPS stamp (geo, bottom of the composed output)
* -> encode. Timings gain frameMs when a frame/stamp pass runs.
*/
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
+24 -1
View File
@@ -41,7 +41,7 @@ export async function nativeBenchProbe(): Promise<void> {
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}`);
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} frameMs=${t.frameMs ?? 0} encodeWriteMs=${t.encodeWriteMs}`);
};
const src: string = await m.materializeBenchAsset('wallframe');
@@ -94,6 +94,29 @@ export async function nativeBenchProbe(): Promise<void> {
const gray100 = await m.materializeSolidAsset(100, 100, 100);
await run('grain10-100', gray100, { grain: 10 });
// P4 frame parity on solid gray100 64x64 — border/bar/card colors and the
// composed output geometry are fully predictable, so probeSamples assert
// exact pixels. Expected (offline verifier re-checks):
// classic-white: (1,1)=white border, (32,32)=100 photo, size 64x64.
// cinematic: (1,1)=black bar, (32,32)=100, size 64x64.
// polaroid: outH=round(64*(SIDE+DECK)+64*WIN_W)=78; (1,1)=card #FAF9F6,
// (32,32)=100 photo, (32,70)=card deck.
await run('frame-classic', gray100, { frameId: 'classic-white', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] });
await run('frame-cinematic', gray100, { frameId: 'cinematic', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }] });
await run('frame-polaroid', gray100, { frameId: 'polaroid', png: true, probeSamples: [{ x: 1, y: 1 }, { x: 32, y: 32 }, { x: 32, y: 70 }] });
// wallframe: export becomes the fixed 3117x4000 rotated artwork; center is
// the cover-cropped photo (100), mat samples compare against wallframe.png
// pixels offline (display->source: (dx,dy)->(dy, 3117-dx)). Art + fonts are
// resolved natively from bundled resources — embedded metro assets expose
// no readable file URI to JS (readAsStringAsync rejects 'wallframe').
await run('frame-wallframe', gray100, { frameId: 'wallframe', png: true, probeSamples: [{ x: 100, y: 1500 }, { x: 2900, y: 200 }, { x: 1559, y: 1998 }] });
// P4 GPS watermark (no frame): 400x400 gray base, stamp bottom-left.
// probeStampDiff logs stampPixelDiff/amber/white after drawing — counts > 0
// prove the emoji + amber text actually rendered (vs a silent no-op).
const gray400 = await m.materializeSolidSized(400, 400, 100, 100, 100);
await run('geo-stamp', gray400, { geo: { locality: 'HANOI TEST', timestamp: Date.now() }, png: true, probeStampDiff: true });
// 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++) {