P5 native export hooked (camera+library) — real photo to gallery on Xiaomi

This commit is contained in:
2026-09-09 16:44:49 +07:00
parent 25e4f21917
commit 1cda62e039
4 changed files with 357 additions and 33 deletions
+54 -27
View File
@@ -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);
@@ -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()
+7
View File
@@ -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. */
+114
View File
@@ -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 <Image> 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<NativeExportResult | null> {
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;
}
}