P4 native frames (classic/polaroid/wallframe) + GPS watermark verified on Xiaomi (sample gates pass)
This commit is contained in:
@@ -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
|
||||
|
||||
+335
-7
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user