P5 native export hooked (camera+library) — real photo to gallery on Xiaomi
This commit is contained in:
+182
-6
@@ -80,7 +80,11 @@ class RecipescamExportModule : Module() {
|
||||
// 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).
|
||||
// 0..10 / -10..10 / 0..10, quality: JPEG 0..100 (default 95),
|
||||
// sharpen: final screen-sharpen amount (exportEngine #9, 0 = skip),
|
||||
// dpi: JPEG header density to patch post-encode (0 = leave as-is),
|
||||
// aspect: composition aspect crop '4:3'/'3:2' computed after decode when
|
||||
// no explicit crop rect is given (WALL FRAME exempt, engine #1b).
|
||||
AsyncFunction("processPhotoAsync") Coroutine { srcPath: String, dstPath: String, matrix: List<*>, crop: Map<String, Any?>?, adjust: Map<String, Any?> ->
|
||||
val ctx = appContext.reactContext ?: error("react context lost")
|
||||
processPipeline(srcPath, dstPath, matrix, crop, adjust, ctx)
|
||||
@@ -238,6 +242,9 @@ private suspend fun processPipeline(
|
||||
val probeSamples = adjust["probeSamples"] as? List<*>
|
||||
val probeStampDiff = adjust["probeStampDiff"] == true
|
||||
val cinemaList = adjust["cinema"] as? List<*>
|
||||
val aspect = adjust["aspect"] as? String
|
||||
val dpi = (adjust["dpi"] as? Number)?.toInt() ?: 0
|
||||
val sharpen = (adjust["sharpen"] as? Number)?.toDouble() ?: 0.0
|
||||
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 }
|
||||
@@ -270,6 +277,27 @@ private suspend fun processPipeline(
|
||||
if (cropped !== bmp) bmp.recycle()
|
||||
bmp = cropped
|
||||
Log.i(TAG, "cropped to ${bmp.width}x${bmp.height}")
|
||||
} else if (aspect == "4:3" || aspect == "3:2") {
|
||||
// Production path: the crop rect depends on decoded dimensions, so the
|
||||
// module computes the same center-largest crop exportEngine #1b does
|
||||
// (JS cannot know the pixel size before decode).
|
||||
val ratioWH = if (aspect == "4:3") 3.0 / 4.0 else 2.0 / 3.0
|
||||
val curAspect = bmp.width.toDouble() / bmp.height.toDouble()
|
||||
var cw = bmp.width
|
||||
var ch = bmp.height
|
||||
var sx = 0
|
||||
var sy = 0
|
||||
if (curAspect > ratioWH) {
|
||||
cw = Math.round(bmp.height * ratioWH).toInt()
|
||||
sx = Math.round((bmp.width - cw) / 2.0).toInt()
|
||||
} else {
|
||||
ch = Math.round(bmp.width / ratioWH).toInt()
|
||||
sy = Math.round((bmp.height - ch) / 2.0).toInt()
|
||||
}
|
||||
val cropped = Bitmap.createBitmap(bmp, sx, sy, cw, ch)
|
||||
if (cropped !== bmp) bmp.recycle()
|
||||
bmp = cropped
|
||||
Log.i(TAG, "aspect $aspect cropped to ${bmp.width}x${bmp.height}")
|
||||
}
|
||||
|
||||
// Tone + cinema both mutate the decoded bitmap in place, and decodeFile/crop
|
||||
@@ -389,15 +417,36 @@ private suspend fun processPipeline(
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Encode + write (JPEG by default; PNG when png=true — probe gates use
|
||||
// 7. Final screen sharpen (exportEngine #9 screenSharpenImage) on the
|
||||
// composed output — same laplacian kernel as the clarity conv, a = amount.
|
||||
// Runs on the final bitmap (frames + GPS stamp included) before encode.
|
||||
if (sharpen > 0) {
|
||||
val tS = System.nanoTime()
|
||||
val wS = render.width
|
||||
val hS = render.height
|
||||
var sp = IntArray(wS * hS)
|
||||
render.getPixels(sp, 0, wS, 0, 0, wS, hS)
|
||||
sp = convClarity(sp, wS, hS, sharpen.toFloat())
|
||||
render.setPixels(sp, 0, wS, 0, 0, wS, hS)
|
||||
timings["sharpenMs"] = ((System.nanoTime() - tS) / 1_000_000).toInt()
|
||||
Log.i(TAG, "screen sharpen applied a=$sharpen")
|
||||
}
|
||||
|
||||
// 8. Encode + write (JPEG by default; PNG when png=true — probe gates use
|
||||
// PNG so lossless pixels are compared exactly, production keeps JPEG).
|
||||
// JPEG goes through memory so the DPI header can be patched (dpi > 0) —
|
||||
// port of src/utils/jpegDpi.ts, same-length writes / APP0 insert.
|
||||
val t4 = System.nanoTime()
|
||||
FileOutputStream(dstPath).use { fos ->
|
||||
if (png) {
|
||||
if (png) {
|
||||
FileOutputStream(dstPath).use { fos ->
|
||||
check(render.compress(Bitmap.CompressFormat.PNG, 100, fos)) { "png encode failed" }
|
||||
} else {
|
||||
check(render.compress(Bitmap.CompressFormat.JPEG, quality, fos)) { "jpeg encode failed" }
|
||||
}
|
||||
} else {
|
||||
val bos = java.io.ByteArrayOutputStream()
|
||||
check(render.compress(Bitmap.CompressFormat.JPEG, quality, bos)) { "jpeg encode failed" }
|
||||
var outBytes = bos.toByteArray()
|
||||
if (dpi > 0) outBytes = patchJpegDpi(outBytes, dpi)
|
||||
FileOutputStream(dstPath).use { fos -> fos.write(outBytes) }
|
||||
}
|
||||
timings["encodeWriteMs"] = ((System.nanoTime() - t4) / 1_000_000).toInt()
|
||||
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"]}")
|
||||
@@ -642,6 +691,133 @@ private fun grainOverlay(px: IntArray, w: Int, h: Int, alpha: Float): IntArray {
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- JPEG DPI header patch — port of src/utils/jpegDpi.ts patchJpegDpi ----
|
||||
// Re-encoding a JPEG through Android drops the original EXIF/JFIF headers, so
|
||||
// the DPI stamp ("72 dpi") is lost or defaulted. DPI is header metadata only —
|
||||
// pixel dimensions never change — but print pipelines read it. All writes are
|
||||
// same-length, so no segment offsets move; when the JPEG carries neither JFIF
|
||||
// nor EXIF a minimal JFIF APP0 segment is inserted right after SOI.
|
||||
private fun u16At(b: ByteArray, p: Int): Int =
|
||||
((b[p].toInt() and 0xFF) shl 8) or (b[p + 1].toInt() and 0xFF)
|
||||
|
||||
private fun patchJpegDpi(input: ByteArray, dpi: Int): ByteArray {
|
||||
if (input.size < 2 || (input[0].toInt() and 0xFF) != 0xFF || (input[1].toInt() and 0xFF) != 0xD8) return input
|
||||
val bytes = input.copyOf() // work on a copy; the insert path rebuilds anyway
|
||||
val dpiLo = dpi and 0xFF
|
||||
val dpiHi = (dpi shr 8) and 0xFF
|
||||
var sawJfif = false
|
||||
var sawExif = false
|
||||
var i = 2
|
||||
while (i + 1 < bytes.size && (bytes[i].toInt() and 0xFF) == 0xFF) {
|
||||
while ((bytes[i].toInt() and 0xFF) == 0xFF) i++
|
||||
val marker = bytes[i].toInt() and 0xFF
|
||||
i++
|
||||
if (marker == 0xD9 || marker == 0xDA) break // EOI / SOS — headers only
|
||||
if (marker == 0x01 || (marker in 0xD0..0xD7)) continue
|
||||
if (i + 2 > bytes.size) break
|
||||
val len = u16At(bytes, i)
|
||||
if (len < 2 || i + len > bytes.size) break
|
||||
val dataStart = i + 2
|
||||
val payloadLen = len - 2
|
||||
// APP0 JFIF: [FF E0] len [5x id "JFIF\0"][2 ver][1 units][2 Xd][2 Yd]...
|
||||
if (marker == 0xE0 && payloadLen >= 14 &&
|
||||
bytes[dataStart].toInt() == 0x4A && bytes[dataStart + 1].toInt() == 0x46 &&
|
||||
bytes[dataStart + 2].toInt() == 0x49 && bytes[dataStart + 3].toInt() == 0x46 &&
|
||||
bytes[dataStart + 4].toInt() == 0x00
|
||||
) {
|
||||
sawJfif = true
|
||||
bytes[dataStart + 7] = 1 // units = dots per inch
|
||||
bytes[dataStart + 8] = dpiHi.toByte()
|
||||
bytes[dataStart + 9] = dpiLo.toByte()
|
||||
bytes[dataStart + 10] = dpiHi.toByte()
|
||||
bytes[dataStart + 11] = dpiLo.toByte()
|
||||
}
|
||||
// APP1 EXIF: [FF E1] len "Exif\0\0" then TIFF
|
||||
if (marker == 0xE1 && payloadLen >= 14 &&
|
||||
bytes[dataStart].toInt() == 0x45 && bytes[dataStart + 1].toInt() == 0x78 &&
|
||||
bytes[dataStart + 2].toInt() == 0x69 && bytes[dataStart + 3].toInt() == 0x66 &&
|
||||
bytes[dataStart + 4].toInt() == 0x00 && bytes[dataStart + 5].toInt() == 0x00
|
||||
) {
|
||||
sawExif = true
|
||||
val tiff = dataStart + 6
|
||||
val le = bytes[tiff].toInt() == 0x49 && bytes[tiff + 1].toInt() == 0x49
|
||||
val be = bytes[tiff].toInt() == 0x4D && bytes[tiff + 1].toInt() == 0x4D
|
||||
if ((le || be) && tiff + 8 <= bytes.size) {
|
||||
fun read32(p: Int): Int = if (le) {
|
||||
(bytes[p].toInt() and 0xFF) or ((bytes[p + 1].toInt() and 0xFF) shl 8) or
|
||||
((bytes[p + 2].toInt() and 0xFF) shl 16) or ((bytes[p + 3].toInt() and 0xFF) shl 24)
|
||||
} else {
|
||||
((bytes[p].toInt() and 0xFF) shl 24) or ((bytes[p + 1].toInt() and 0xFF) shl 16) or
|
||||
((bytes[p + 2].toInt() and 0xFF) shl 8) or (bytes[p + 3].toInt() and 0xFF)
|
||||
}
|
||||
fun write32(p: Int, v: Int) {
|
||||
if (le) {
|
||||
bytes[p] = (v and 0xFF).toByte()
|
||||
bytes[p + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
bytes[p + 2] = ((v shr 16) and 0xFF).toByte()
|
||||
bytes[p + 3] = ((v shr 24) and 0xFF).toByte()
|
||||
} else {
|
||||
bytes[p] = ((v shr 24) and 0xFF).toByte()
|
||||
bytes[p + 1] = ((v shr 16) and 0xFF).toByte()
|
||||
bytes[p + 2] = ((v shr 8) and 0xFF).toByte()
|
||||
bytes[p + 3] = (v and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
fun u16r(p: Int): Int = if (le) {
|
||||
(bytes[p].toInt() and 0xFF) or ((bytes[p + 1].toInt() and 0xFF) shl 8)
|
||||
} else {
|
||||
((bytes[p].toInt() and 0xFF) shl 8) or (bytes[p + 1].toInt() and 0xFF)
|
||||
}
|
||||
fun u16w(p: Int, v: Int) {
|
||||
if (le) {
|
||||
bytes[p] = (v and 0xFF).toByte()
|
||||
bytes[p + 1] = ((v shr 8) and 0xFF).toByte()
|
||||
} else {
|
||||
bytes[p] = ((v shr 8) and 0xFF).toByte()
|
||||
bytes[p + 1] = (v and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
val ifdOff = read32(tiff + 4)
|
||||
if (ifdOff + 2 <= bytes.size - tiff) {
|
||||
val ifd = tiff + ifdOff
|
||||
val count = u16r(ifd)
|
||||
for (e in 0 until count) {
|
||||
val entry = ifd + 2 + e * 12
|
||||
if (entry + 12 > bytes.size) break
|
||||
val tag = u16r(entry)
|
||||
val type = u16r(entry + 2)
|
||||
val cnt = read32(entry + 4)
|
||||
if ((tag == 0x011A || tag == 0x011B) && type == 5 && cnt == 1) {
|
||||
// RATIONAL: entry+8 holds the (num,den) offset, relative to TIFF start.
|
||||
val rat = tiff + read32(entry + 8)
|
||||
if (rat + 8 <= bytes.size) write32(rat, dpi) // keep denominator
|
||||
} else if (tag == 0x0128 && type == 3 && cnt == 1) {
|
||||
u16w(entry + 8, 2) // ResolutionUnit = inch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += len
|
||||
}
|
||||
if (sawJfif || sawExif) return bytes
|
||||
// Neither segment exists — insert a minimal JFIF APP0 (density = dpi inch).
|
||||
val app0 = byteArrayOf(
|
||||
0xFF.toByte(), 0xE0.toByte(), 0x00, 0x10,
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
|
||||
0x01, 0x01, // version 1.01
|
||||
0x01, // units: inch
|
||||
dpiHi.toByte(), dpiLo.toByte(), dpiHi.toByte(), dpiLo.toByte(),
|
||||
0x00, 0x00 // no thumbnail
|
||||
)
|
||||
val out = ByteArray(bytes.size + app0.size)
|
||||
out[0] = 0xFF.toByte()
|
||||
out[1] = 0xD8.toByte()
|
||||
System.arraycopy(app0, 0, out, 2, app0.size)
|
||||
System.arraycopy(bytes, 2, out, 2 + app0.size, bytes.size - 2)
|
||||
return out
|
||||
}
|
||||
|
||||
private suspend fun decodeEncodeAsync(srcPath: String, dstPath: String, quality: Int): Map<String, Int> {
|
||||
val timings = mutableMapOf<String, Int>()
|
||||
val t0 = System.nanoTime()
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ColorTimings {
|
||||
clarityMs?: number;
|
||||
grainMs?: number;
|
||||
frameMs?: number;
|
||||
sharpenMs?: number;
|
||||
encodeWriteMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
@@ -49,6 +50,12 @@ export interface PhotoAdjust {
|
||||
grain?: number;
|
||||
/** JPEG quality 0..100; default 95. */
|
||||
quality?: number;
|
||||
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
|
||||
sharpen?: number;
|
||||
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
|
||||
dpi?: number;
|
||||
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
|
||||
aspect?: '4:3' | '3:2' | 'full';
|
||||
/** 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. */
|
||||
|
||||
Reference in New Issue
Block a user