// 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 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'; import { ensureMediaLibraryPermission } from './mediaPermission'; import { nextPhotoPath, stampExifOnFile } from './photoMeta'; 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; // 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; // Custom text watermark overlay — position fractions of the photo area, same // contract as ExportOptions.watermark. SHIPPED but NOT drawn yet: the Kotlin // module renders geo captions but has no text-watermark draw (ponytail: add // it when native parity with the Skia engine is completed). watermark?: { text: string; x: number; y: number } | null; // Gallery filename + EXIF, same contract as ExportOptions.exported / // .capturedAt: RC__exported.jpg keeps the loaded photo's own EXIF. exported?: boolean; capturedAt?: number | null; } // The Kotlin module reads plain absolute paths, not file:// URIs. const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); 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 ); // Base filter is passed so a stock's own tone pass is picked up, but the // Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg) // and the Color Chrome/Chrome-Blue depths are preview/engine only, so a // native-export build silently drops those three. ponytail: port them into // applyTone() when the native path stops being a dev probe // (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle). const tone = getToneUniforms(adjustments, recipe.baseFilter); const cinema = getCinemaUniforms(recipe.cinema); // Final screen sharpen (engine #9): the sharpening knob (-10..10) is the // only source — the old 0.5 fallback on every camera capture multiplied // sensor noise ~3.2x and made dim/high-ISO shots unusable. const sharpKnob = adjustments.sharpening ?? 0; const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 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, // Negative knob -> Kotlin soften blur, pre-grain (engine step 4). soften: sharpKnob < 0 ? (Math.abs(sharpKnob) / 10) * 3 : undefined, dpi: options?.dpi && options.dpi > 0 ? options.dpi : undefined, aspect, }; const exportFile = nextPhotoPath(options?.exported === true); // file:// URI (cacheDirectory) const dstPath = stripScheme(exportFile); await RecipescamExport.processPhotoAsync(stripScheme(sourceUri), dstPath, matrix, null, adjust); // Same EXIF contract as the Skia engine (step 9c). ponytail: pixel // dimensions are left out here — Kotlin knows the rendered size, JS does not. await stampExifOnFile( exportFile, { dateTime: options?.capturedAt ? new Date(options.capturedAt) : null, gps: useGeotag && gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null, dpi: options?.dpi ?? null, }, sourceUri ); const mediaGranted = await ensureMediaLibraryPermission(); if (mediaGranted) { 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; } }