From 1cda62e0393e9c4063807fec6d1a0f0c10e53cf4 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Wed, 9 Sep 2026 16:44:49 +0700 Subject: [PATCH] =?UTF-8?q?P5=20native=20export=20hooked=20(camera+library?= =?UTF-8?q?)=20=E2=80=94=20real=20photo=20to=20gallery=20on=20Xiaomi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App.tsx | 81 +++++--- .../export/RecipescamExportModule.kt | 188 +++++++++++++++++- modules/recipescam-export/index.ts | 7 + src/utils/nativeExport.ts | 114 +++++++++++ 4 files changed, 357 insertions(+), 33 deletions(-) create mode 100644 src/utils/nativeExport.ts diff --git a/App.tsx b/App.tsx index 75adb71..4977d44 100644 --- a/App.tsx +++ b/App.tsx @@ -22,6 +22,7 @@ import { DEFAULT_ADJUSTMENTS } from './src/utils/defaultRecipes'; import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils'; import { resolvePhotoGPS } from './src/utils/photoGps'; import { processAndExportPhoto } from './src/utils/exportEngine'; +import { processAndExportPhotoNative } from './src/utils/nativeExport'; import { PARAM_DEFS } from './src/utils/paramDefs'; // @ts-ignore @@ -29,6 +30,12 @@ import './global.css'; const SESSION_KEY = 'saved_session'; +// P5: native Kotlin export path. EXPO_PUBLIC_NATIVE_EXPORT=1 swaps the Skia +// render engine for the RecipesCamExport native pipeline (same look contract, +// rendering off the JS thread). Default stays legacy until P6 parity/perf gate +// decides which engine ships. +const NATIVE_EXPORT = process.env.EXPO_PUBLIC_NATIVE_EXPORT === '1'; + export default function App() { const [mode, setMode] = useState<'camera' | 'library'>('camera'); // Imperative handle into the Viewfinder camera controller: flushes the EV to @@ -508,24 +515,29 @@ export default function App() { // next shot can be taken immediately; no full-screen RENDERING overlay. // Finished photos land in history (and the gallery) when their turn // completes; the thumbnail button shows each as it arrives. + // P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport — + // same queue/pushPhoto/Haptics/Alert behavior, different renderer. const recipeName = activeRecipe.name; - const render = () => - processAndExportPhoto( - sourceUri, - { ...activeRecipe, adjustments: renderAdj, frameId: selectedFrame, useGeotag }, - selectedFrame, + const render = () => { + const recipeArgs = { + ...activeRecipe, + adjustments: renderAdj, + frameId: selectedFrame, useGeotag, - captureGps, + }; + const options = { // 300dpi header + full-res screen sharpen. evFromCamera: the AE bias // already applied exposure compensation at capture — don't add 2^EV. // aspect: crop to the on-screen composition ratio (full = keep 4:3 native). - { - dpi: 300, - sharpen: true, - evFromCamera: true, - aspect: aspectRatio === 'full' ? undefined : aspectRatio, - } - ); + dpi: 300, + sharpen: true, + evFromCamera: true, + aspect: aspectRatio === 'full' ? undefined : aspectRatio, + }; + return NATIVE_EXPORT + ? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options) + : processAndExportPhoto(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options); + }; enqueueExport(async () => { try { const result = await render(); @@ -565,20 +577,35 @@ export default function App() { setIsProcessing(true); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); - const result = await processAndExportPhoto( - libraryImageUri, - { - ...selectedRecipe, - adjustments, - frameId: selectedFrame, - useGeotag, - }, - selectedFrame, - useGeotag, - gpsInfo, - // Library export follows the same composition ratio as the viewfinder band. - { aspect: aspectRatio === 'full' ? undefined : aspectRatio } - ); + const result = NATIVE_EXPORT + ? await processAndExportPhotoNative( + libraryImageUri, + { + ...selectedRecipe, + adjustments, + frameId: selectedFrame, + useGeotag, + }, + selectedFrame, + useGeotag, + gpsInfo, + // Library export follows the same composition ratio as the viewfinder band. + { aspect: aspectRatio === 'full' ? undefined : aspectRatio } + ) + : await processAndExportPhoto( + libraryImageUri, + { + ...selectedRecipe, + adjustments, + frameId: selectedFrame, + useGeotag, + }, + selectedFrame, + useGeotag, + gpsInfo, + // Library export follows the same composition ratio as the viewfinder band. + { aspect: aspectRatio === 'full' ? undefined : aspectRatio } + ); setIsProcessing(false); 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 8f1d144..93f2b86 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 @@ -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?, adjust: Map -> 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 { val timings = mutableMapOf() val t0 = System.nanoTime() diff --git a/modules/recipescam-export/index.ts b/modules/recipescam-export/index.ts index 8105b19..27ef260 100644 --- a/modules/recipescam-export/index.ts +++ b/modules/recipescam-export/index.ts @@ -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. */ diff --git a/src/utils/nativeExport.ts b/src/utils/nativeExport.ts new file mode 100644 index 0000000..72470ac --- /dev/null +++ b/src/utils/nativeExport.ts @@ -0,0 +1,114 @@ +// P5 native export path: mirrors exportEngine.processAndExportPhoto's pipeline +// contract (same recipe math, same output file/gallery behavior) but renders +// inside the RecipesCamExport Kotlin module — decode/crop/tone/cinema/matrix/ +// denoise/clarity/grain/frame/GPS/sharpen run off the JS thread and the JPEG is +// encoded straight to disk (DPI header patched natively). JS only computes the +// parameter set with the same helpers exportEngine uses, then saves to gallery. +// Deliberately does NOT import exportEngine or Skia: when the native path is +// active the Skia engine must not even load. +import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as MediaLibrary from 'expo-media-library/legacy'; +import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments } from '../types'; +import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; +import { getToneUniforms } from './toneShader'; +import { getCinemaUniforms } from './cinemaShader'; + +export interface NativeExportResult { + uri: string; + savedToLibrary: boolean; + assetId?: string | null; +} + +export interface NativeExportOptions { + // DPI to stamp into the JPEG header (metadata only — pixel size never changes). + dpi?: number; + // Apply the subtle "Screen" output sharpen (exportEngine #9) to the final + // pixels before encoding (legacy camera captures pass sharpen: true → 0.5). + sharpen?: boolean; + // True for photos captured through the live camera: the AE bias already + // applied exposure compensation at capture, so no 2^EV matrix gain is added. + evFromCamera?: boolean; + // Composition aspect-ratio crop ('full'/undefined = none). Native computes the + // center-largest rect after decode because the pixel size is only known there. + aspect?: AspectRatio; +} + +// The Kotlin module reads plain absolute paths, not file:// URIs. +const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); + +// Unique filename per export (same cache-bust rationale as exportEngine: a fixed +// path would serve stale cache entries). +const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`; + +export async function processAndExportPhotoNative( + sourceUri: string, + recipe: Recipe, + frameId: FrameId, + useGeotag: boolean, + gpsInfo: GPSInfo | null, + options?: NativeExportOptions +): Promise { + try { + const adjustments: ColorAdjustments = recipe.adjustments; + // Same matrix rule as exportEngine #3: camera captures skip 2^EV (hardware + // AE bias already applied it), library stills get the matrix gain. + const matrix = options?.evFromCamera + ? getSkiaColorMatrix(recipe.baseFilter, adjustments) + : applyExposureGain( + getSkiaColorMatrix(recipe.baseFilter, adjustments), + adjustments.exposureCompensation ?? 0 + ); + const tone = getToneUniforms(adjustments); + const cinema = getCinemaUniforms(recipe.cinema); + // Final screen sharpen (engine #9): the sharpening knob (0..10) overrides + // the legacy boolean sharpen flag (camera captures keep it true → 0.5). + const sharpKnob = adjustments.sharpening ?? 0; + const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0; + const aspect: '4:3' | '3:2' | undefined = + options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe' + ? options.aspect + : undefined; + + const adjust: PhotoAdjust = { + toneDr: tone.dr, + toneHl: tone.hl, + toneSh: tone.sh, + cinema: cinema ? cinema.flat : null, + denoise: adjustments.denoise, + clarity: adjustments.clarity, + grain: adjustments.grain, + quality: 95, // engine encodes at fixed JPEG 95 + frameId, + // Fonts/artwork are bundled in the APK (res/raw assets_*, drawable + // wallframe) — native reads them directly; no base64 bridge needed. + geo: + useGeotag && gpsInfo + ? { locality: gpsInfo.locality || 'STREET VIEW', timestamp: gpsInfo.timestamp } + : null, + sharpen: sharpenAmount > 0 ? sharpenAmount : undefined, + dpi: options?.dpi && options.dpi > 0 ? options.dpi : undefined, + aspect, + }; + + const exportFile = nextExportFile(); // file:// URI (cacheDirectory) + const dstPath = stripScheme(exportFile); + await RecipescamExport.processPhotoAsync(stripScheme(sourceUri), dstPath, matrix, null, adjust); + + const mediaPermission = await MediaLibrary.requestPermissionsAsync(); + if (mediaPermission.granted) { + try { + const asset = await MediaLibrary.createAssetAsync(exportFile); + return { uri: exportFile, savedToLibrary: true, assetId: asset.id }; + } catch (e) { + console.warn('Save to gallery failed:', e); + } + } else { + console.warn('Media Library permission denied. Image saved to temporary cache only.'); + } + return { uri: exportFile, savedToLibrary: false }; + } catch (error) { + console.error('Error during native photo processing and export:', error); + return null; + } +}