import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia'; import { File } from 'expo-file-system'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library/legacy'; import { Asset } from 'expo-asset'; import { Recipe, GPSInfo, FrameId } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; export interface ExportResult { uri: string; savedToLibrary: boolean; } export interface ExportOptions { // DPI to stamp into the JPEG header (metadata only — pixel size never changes). dpi?: number; // Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the // final pixels before encoding. Pixels already export at full resolution. sharpen?: boolean; // True for photos captured through the live camera: exposure compensation was // already applied as a hardware AE bias at capture, so no 2^EV matrix gain // should be added on top. Library stills omit this and get the matrix gain. evFromCamera?: boolean; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 // convolution whose kernel sums to 1 (brightness preserved), amount ~0.5. // ponytail: fixed subtle preset tuned for full-res desktop/screen output; turn // into an amount/radius UI control if users ask for adjustable strength. // GPU-backed surface when the Skia GPU context is available (convolutions/sharpen // run on the Adreno shader cores instead of the CPU — big win on 50MP captures), // falling back to the CPU raster surface. MakeOffscreen returns null when no GPU // context exists yet, so the fallback keeps this safe in library-only cold starts. function createSurface(width: number, height: number): SkSurface | null { return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height); } function screenSharpenImage(image: SkImage, amount = 0.5): SkImage { const w = image.width(); const h = image.height(); const center = 1 + 4 * amount; const filter = Skia.ImageFilter.MakeMatrixConvolution( 3, 3, [0, -amount, 0, -amount, center, -amount, 0, -amount, 0], 1, 0, 1, 1, TileMode.Clamp, false, null, null ); const surf = createSurface(w, h); if (!surf) return image; // fall back to unsharpened on allocation failure const paint = Skia.Paint(); paint.setImageFilter(filter); const c = surf.getCanvas(); c.clear(Skia.Color('transparent')); c.drawImage(image, 0, 0, paint); const sharpened = surf.makeImageSnapshot(); if (!sharpened) return image; return sharpened; } // Unique filename per export. A fixed path made every export share one URI, and // RN's cache (keyed by URI) then kept showing the previous photo in the // thumbnail after a new capture overwrote the file. A fresh URI per export busts // that cache. Previous export file is deleted after the new one is written and // gallery-saved, so at most one stale temp file can ever exist. let lastExportPath: string | null = null; const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`; export async function processAndExportPhoto( sourceUri: string, recipe: Recipe, frameId: FrameId, useGeotag: boolean, gpsInfo: GPSInfo | null, options?: ExportOptions ): Promise { const t0 = Date.now(); // Direct console.error templates (user log captures are E-level only). Steps // use full static prefixes — release minification dropped step literals when // they were passed through a wrapper, so no indirection here. try { // 1. Read source image file into Skia const skiaData = await Skia.Data.fromURI(sourceUri); if (!skiaData) { console.error('Failed to load image data from URI'); return null; } const skImage = Skia.Image.MakeImageFromEncoded(skiaData); if (!skImage) { console.error('Failed to parse image from URI'); return null; } console.error(`[export] decode ${Date.now() - t0}ms`); const width = skImage.width(); const height = skImage.height(); console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`); // 2. Create offscreen canvas (GPU-backed when available — see createSurface) const surface = createSurface(width, height); if (!surface) { console.error('Failed to create Skia surface'); return null; } const canvas = surface.getCanvas(); const paint = Skia.Paint(); const adjustments = recipe.adjustments; // 3. Build Color Matrix Filter (+2^EV gain for library stills; camera // captures already got the bias at exposure time → evFromCamera skips it). const evStops = adjustments.exposureCompensation ?? 0; const evMatrix = options?.evFromCamera ? getSkiaColorMatrix(recipe.baseFilter, adjustments) : applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops); const colorFilter = Skia.ColorFilter.MakeMatrix(evMatrix); paint.setColorFilter(colorFilter); // 3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix. // Keep the built shader in its own variable: only drawRect (which samples // through the shader) when a real shader exists. If the RuntimeEffect fails // to compile / makeShaderWithChildren returns null, paint has no shader and a // drawRect would fill solid opaque color → black/blank output. const tone = getToneUniforms(adjustments); const hasTone = toneIsActive(tone); let toneShader: SkShader | null = null; if (hasTone) { let toneEffect: ReturnType = null; try { toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL); } catch (e) { console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e); } if (toneEffect) { const imageShader = skImage.makeShaderOptions( TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None ); toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh], [imageShader]); if (toneShader) { paint.setShader(toneShader); } else { console.error('[export] tone shader build failed - falling back to matrix-only'); } } else { console.error('[export] TONE_SKSL compile failed - falling back to matrix-only'); } } // 3c. Cinema seasonal atmosphere grade (chain: cinema -> tone -> image). // All-zero uniforms (no recipe.cinema) → skipped entirely. const cinema = getCinemaUniforms(recipe.cinema); let paintShader = toneShader; if (cinema && cinemaIsActive(cinema.flat)) { try { const cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL); if (cinemaEffect) { const child = toneShader ?? skImage.makeShaderOptions( TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None ); const cs = cinemaEffect.makeShaderWithChildren(cinema.flat, [child]); if (cs) { paintShader = cs; paint.setShader(paintShader); } } } catch (e) { console.error('[export] CINEMA_SKSL failed - skipped: ' + e); } } // 4. Denoise / Clarity Image Filters (export-only enhancements) let imageFilter = null; // Denoise (Subtle blur to smooth noise) if (adjustments.denoise > 0) { const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px imageFilter = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null); } // Clarity (Convolution Sharpening if positive) if (adjustments.clarity > 0) { const sharpAmount = (adjustments.clarity / 10) * 0.8; const kernel = [ 0, -sharpAmount, 0, -sharpAmount, 1 + 4 * sharpAmount, -sharpAmount, 0, -sharpAmount, 0, ]; const sharpenFilter = Skia.ImageFilter.MakeMatrixConvolution( 3, 3, kernel, 1, 0, 0, 0, TileMode.Clamp, true, imageFilter ); if (sharpenFilter) { imageFilter = sharpenFilter; } } else if (adjustments.clarity < 0) { // Bloom/mist effect if negative const mistSigma = Math.abs(adjustments.clarity / 10) * 4; const mistFilter = Skia.ImageFilter.MakeBlur(mistSigma, mistSigma, TileMode.Clamp, imageFilter); if (mistFilter) { imageFilter = mistFilter; } } if (imageFilter) { paint.setImageFilter(imageFilter); } // 5. Draw primary image with color filters & enhancements. Tone path samples // the image through the paint shader (child imageShader) with a rect so the // whole chain (tone → matrix → denoise/clarity) applies — but only when a // real shader was built; otherwise plain drawImage (never drawRect on a // shader-less paint, which paints solid color). if (paintShader) { canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint); } else { canvas.drawImage(skImage, 0, 0, paint); } // 6. Add Monochrome Grain Overlay if (adjustments.grain > 0) { const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity const grainPaint = Skia.Paint(); grainPaint.setBlendMode(BlendMode.Overlay); grainPaint.setAlphaf(grainOpacity); // Procedural noise shader const noiseEffect = Skia.RuntimeEffect.Make(` vec4 main(vec2 pos) { float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453); return vec4(vec3(r), 1.0); } `); const noiseShader = noiseEffect ? noiseEffect.makeShader([]) : null; if (noiseShader) { grainPaint.setShader(noiseShader); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint); } } // 7. Frame. RETRO POLAROID is not a border drawn on the photo: the whole // processed picture is composited, uncropped, into the window of a bigger // off-white card (real print proportions, see frameUtils) — the export IS // the card, with the photo scaled to the window so it keeps ~WIN_W of the // card width. Other frames (classic white border, legacy cinematic bars) // draw over the photo-sized canvas as before. let outputSurface: SkSurface = surface; let stampCanvas = canvas; let stampW = width; let stampH = height; let polaroidWindow: { x: number; y: number; w: number; h: number } | null = null; if (frameId === 'polaroid') { const photoImg = surface.makeImageSnapshot(); const outW = width; // card width matches the photo width const outH = Math.round( width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + height * POLAROID_WIN_W ); const cardSurface = createSurface(outW, outH); if (!cardSurface) { console.error('Failed to create polaroid card surface'); return null; } const cardCanvas = cardSurface.getCanvas(); const bgPaint = Skia.Paint(); bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint); const pl = polaroidLayout(outW, outH, width / height, 'contain'); const win = pl.window; // Window aspect == photo aspect → fills exactly, no letterbox inside. const plainPaint = Skia.Paint(); cardCanvas.drawImageRect( photoImg, Skia.XYWHRect(0, 0, width, height), Skia.XYWHRect(win.x, win.y, win.w, win.h), plainPaint ); // Thin seam separating image window from the card. const seamPaint = Skia.Paint(); seamPaint.setColor(Skia.Color('#e5e5e5')); seamPaint.setStyle(1); seamPaint.setStrokeWidth(1); cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint); outputSurface = cardSurface; stampCanvas = cardCanvas; stampW = outW; stampH = outH; polaroidWindow = win; } else { drawFrameOnCanvas(canvas, width, height, frameId); } // 8. Render GPS Geotag Watermark if (useGeotag && gpsInfo) { const fontAsset = Asset.fromModule(require('../../assets/Cousine-Regular.ttf')); const iconFontAsset = Asset.fromModule(require('../../assets/NotoEmoji-GPS.ttf')); await Promise.all([fontAsset.downloadAsync(), iconFontAsset.downloadAsync()]); const [fontData, iconFontData] = await Promise.all([ FileSystem.readAsStringAsync(fontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64 }), FileSystem.readAsStringAsync(iconFontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64 }), ]); const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(fontData)); const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(iconFontData)); if (typeface && iconTypeface) { const fontSize = Math.round(stampW * 0.032); // 3.2% of output width const font = Skia.Font(typeface, fontSize); const iconFont = Skia.Font(iconTypeface, fontSize); const textPaint = Skia.Paint(); textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500 const iconPaint = Skia.Paint(); iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text const locationName = gpsInfo.locality || 'STREET VIEW'; const d = new Date(gpsInfo.timestamp); const pad = (n: number) => String(n).padStart(2, '0'); const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em) // Polaroid: caption at the bottom-left inside the photo window (the // spot the deck edge meets the print). Other frames: bottom-left of // the image, clear of the edge. let baseX: number; let baseY: number; if (polaroidWindow) { baseX = polaroidWindow.x + polaroidWindow.w * 0.05; baseY = polaroidWindow.y + polaroidWindow.h - Math.round(fontSize * 1.4); } else { baseX = fontSize * 1.2; baseY = stampH - fontSize * 1.5; } stampCanvas.drawText('📍', baseX, baseY - fontSize * 1.4, iconPaint, iconFont); stampCanvas.drawText(locationName, baseX + gap, baseY - fontSize * 1.4, textPaint, font); stampCanvas.drawText('📷', baseX, baseY, iconPaint, iconFont); stampCanvas.drawText(timestampStr, baseX + gap, baseY, textPaint, font); } } // 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip) console.error(`[export] composite ${Date.now() - t0}ms`); let resultImage = outputSurface.makeImageSnapshot(); // Sharpening knob (0..10) overrides the legacy boolean sharpen flag: // legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0. const sharpKnob = adjustments.sharpening ?? 0; const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0; if (sharpenAmount > 0) { resultImage = screenSharpenImage(resultImage, sharpenAmount); } console.error(`[export] sharpen ${Date.now() - t0}ms`); // Encode straight to raw JPEG bytes — no base64. The old path round-tripped // the whole 12MP frame through JS base64 decode/encode for the DPI patch and // then writeAsStringAsync, which alone took ~6s of the render. let bytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95); if (!bytes || bytes.length === 0) { console.error('Failed to encode image to JPEG'); return null; } console.error(`[export] encode ${Date.now() - t0}ms`); // 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested — // in-place Uint8Array work, no base64 round-trip. if (options?.dpi && options.dpi > 0) { bytes = patchJpegDpi(bytes, options.dpi); } // 10. Write binary bytes to a unique temporary local file. The unique name // is load-bearing: a fixed URI would be served from RN's cache and // the thumbnail/preview would keep showing the previous export. const exportFile = nextExportFile(); try { new File(exportFile).write(bytes); } catch (e) { console.error('Failed to write export file: ' + e); return null; } console.error(`[export] write ${Date.now() - t0}ms`); console.error(`[export] total-done ${Date.now() - t0}ms`); // 11. Request Media Library permission & Save to device gallery const mediaPermission = await MediaLibrary.requestPermissionsAsync(); if (mediaPermission.granted) { try { await MediaLibrary.saveToLibraryAsync(exportFile); // New file is safely in the gallery — retire the previous export temp. if (lastExportPath && lastExportPath !== exportFile) { try { await FileSystem.deleteAsync(lastExportPath, { idempotent: true }); } catch (e) { console.warn('Cleanup of previous export temp failed:', e); } } lastExportPath = exportFile; return { uri: exportFile, savedToLibrary: true }; } catch (e) { console.warn('Save to gallery failed:', e); } } else { console.warn('Media Library permission denied. Image saved to temporary cache only.'); } console.error(`[export] gallery-save ${Date.now() - t0}ms`); return { uri: exportFile, savedToLibrary: false }; } catch (error) { console.error('Error during photo processing and export:', error); return null; } }