From 25e4f21917ff232c1c16416695b8a6284b792c4d Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 16:34:24 +0700 Subject: [PATCH] P4 native frames (classic/polaroid/wallframe) + GPS watermark verified on Xiaomi (sample gates pass) --- PLAN.md | 2 +- .../export/RecipescamExportModule.kt | 342 +++++++++++++++++- modules/recipescam-export/index.ts | 21 +- src/dev/nativeBenchProbe.ts | 25 +- 4 files changed, 380 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index fa77ead..91d6af4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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 2725fdf..8f1d144 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 @@ -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() + 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?, adjust: Map -> - 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?, 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?, adjust: Map, + ctx: Context? = null, ): Map { 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 + 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() 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_ 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 { + 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) +} diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index 1c9d014..8105b19 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -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; /** P1 alias: matrix + crop, no tone/cinema/enhance passes. */ diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts index 56ef1b8..8b06149 100644 --- a/src/dev/nativeBenchProbe.ts +++ b/src/dev/nativeBenchProbe.ts @@ -41,7 +41,7 @@ export async function nativeBenchProbe(): Promise { 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 { 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++) {