P5 native export hooked (camera+library) — real photo to gallery on Xiaomi
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
+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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user