import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, FontStyle, type SkImage, type SkShader, type SkSurface, type SkTypeface } 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, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray, GLOW_SKSL, glowUniformArray } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; import { GRAIN_SKSL, GRAIN_SEED, grainCell } from './grainShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; import { writeJpegExif } from './exifWrite'; import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta'; import { ensureMediaLibraryPermission } from './mediaPermission'; import RecipescamExport from '../../modules/recipescam-export'; const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); import { applyPhotoRotation } from './skiaImage'; export interface ExportResult { uri: string; savedToLibrary: boolean; // MediaLibrary asset id when the photo was saved to the gallery — lets the // photo viewer's trash button remove the gallery copy too. assetId?: string | null; } 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; // Composition aspect-ratio crop (upright 4:3 or 3:2). Center-largest crop // applied right after decode — every later step (frame/GPS/sharpen/size) // then runs on the cropped pixels, so the export matches the ratio the // viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed // opening and export size). 'full'/undefined = no crop. aspect?: AspectRatio; // FRAME-tab CROP, fixed ratio: width/height of the kept area, center-cropped // exactly like `aspect` but for every non-full ratio. Wins over `aspect`. cropWH?: number; // FRAME-tab CROP, FREE: the kept rectangle as 0..1 fractions of the // (post-rotation) source image. Wins over `cropWH` and `aspect`. WALL FRAME // ignores both (fixed frame opening, cover-cropped by the artwork itself). cropRect?: CropRect | null; // Custom text watermark: typed text + 0..1 fractions of the photo area // (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay, // so the export lands where the preview showed it). Amber mono, no icons. watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null; } | null; // The free build's own mark (RECIPESCAM), drawn in ADDITION to `watermark`: // the custom text is free in LITE, the mark is what LITE leaves behind. liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null; } | null; // WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to // BOTH the GPS lines and the custom text, each around its own anchor (the // same anchor Viewfinder rotates around, so the preview matches the file). watermarkRotation?: number; // WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same // area the custom mark uses (a framed export's canvas IS the frame) plus a // multiplier on the base 3.2%-of-width size. Absent = the old top-left look. gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null; // WALL FRAME hung the other way round (4000x3117, artwork un-rotated). wallframeLandscape?: boolean; // Library framed-window photo reposition (Viewfinder.getFrameWindowZoom): // after pinch/drag inside a polaroid card window or a wall-frame opening, // s = scale and u/v = the fraction of the photo's cover plane sitting at the // WINDOW CENTER. The export draws the source sub-rectangle that stays // visible through the opening, so the file matches the preview. Null / // undefined = centered 1x (the historical full-image draw). frameWindowZoom?: { s: number; u: number; v: number } | null; // Library FRAME-tab rotation: the quarter turn in degrees clockwise // (0/90/180/270) and the fine STRAIGHTEN offset in degrees, applied to the // decoded source before the ratio crop, so the frame layout and cover maths // see exactly the orientation the preview showed. photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; // This export came from a photo loaded out of the library (not a live // capture): the file is named RC__exported.jpg and the loaded photo's // own EXIF lives on in the output. exported?: boolean; // Shutter instant of a live capture (ms). Absent = keep the loaded photo's // own DateTimeOriginal. capturedAt?: number | null; } // 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); } // Every Skia surface and image this module allocates is a native buffer — a // 12MP RGBA surface is ~48MB — held only by a JS local. Hermes does not collect // them anywhere near fast enough: one session of captures had the app holding // 174MB of native heap, 125MB of EGL textures and another 218MB swapped out, // with the device down to ~20MB free, so every tap, capture and gallery save // page-faulted for seconds. Each buffer is registered with `own` and released // by hand before the export returns. A snapshot outlives its surface: Skia // refcounts the pixels. type SkDisposable = { dispose(): void }; function disposeAll(items: SkDisposable[]): void { // Reverse creation order: a paint holds the shader/filter it was given, a // shader holds the image it samples. Same native lifetime either way (Skia // refcounts), but releasing the dependents first frees the 48MB 12MP buffers // on this very call instead of on the next Hermes GC. for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; try { item.dispose(); } catch (e) { console.warn('Skia dispose failed:', e); } } } // Free one owned buffer before the finally block runs: drop it from the // registry so disposeAll does not touch it twice, then dispose it now. Used // where a pipeline step makes a full-res buffer dead mid-render (a 12MP // surface is 48MB and the device has ~450MB free while shooting). function release(owned: SkDisposable[], item: SkDisposable | null): void { if (!item) return; const i = owned.indexOf(item); if (i >= 0) owned.splice(i, 1); disposeAll([item]); } 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) { // fall back to unsharpened on allocation failure filter.dispose(); return image; } const paint = Skia.Paint(); try { 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; } finally { // The snapshot holds its own ref on the pixels, so the full-res copy can go // now instead of waiting for the caller to finish. disposeAll([paint, filter]); surf.dispose(); } } // The watermark faces are 300KB of base64 + a FreeType parse apiece, re-done on // every export. They never change, so parse once and keep them for the life of // the process (module scope, like Viewfinder's runtime effects). let watermarkFonts: { typeface: SkTypeface; iconTypeface: SkTypeface } | null = null; async function loadWatermarkFonts() { if (watermarkFonts) return watermarkFonts; 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) return null; watermarkFonts = { typeface, iconTypeface }; return watermarkFonts; } export async function processAndExportPhoto( sourceUri: string, recipe: Recipe, frameId: FrameId, useGeotag: boolean, gpsInfo: GPSInfo | null, options?: ExportOptions ): Promise { // Released in the finally below — see SkDisposable. const owned: SkDisposable[] = []; // accepts possibly-null factory results so the call sites stay one-liners const own = (obj: T | null | undefined): T => { if (obj) owned.push(obj); return obj as T; }; 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; } own(skiaData); const skImage0 = Skia.Image.MakeImageFromEncoded(skiaData); if (!skImage0) { console.error('Failed to parse image from URI'); return null; } own(skImage0); // The encoded source bytes are not read again: the decode owns its own // pixels and EXIF is re-read from the file later. release(owned, skiaData); let skImage = skImage0; // FRAME-tab rotation first: the pixel-dimension swap then propagates // through the ratio crop, frame layout and cover maths unchanged. // applyPhotoRotation hands back the SAME image when there is no rotation, // so only a fresh one is owned (owning the input twice would dispose it // twice). const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten); if (rotated && rotated !== skImage) { own(rotated); // Un-rotated source: superseded, never read again. release(owned, skImage); skImage = rotated; } let width = skImage.width(); let height = skImage.height(); // 1b. Aspect-ratio crop (composition): center-largest upright crop of the // requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen // all apply to exactly the pixels the user framed. Wall Frame is exempt — // its export is the fixed frame artwork whose opening cover-crops anyway. const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null; // FRAME-tab CROP (the App only passes it for the plain frames): an explicit // FREE rectangle wins, then a fixed crop ratio, then the camera composition // ratio. const cropRect = options?.cropRect ?? null; const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH; if ((cropRect || ratioWH) && frameId !== 'wallframe') { const curAspect = width / height; let cropW = width; let cropH = height; let srcX = 0; let srcY = 0; if (cropRect) { srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width))); srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height))); cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width))); cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height))); } else if (curAspect > (ratioWH as number)) { cropW = Math.round(height * (ratioWH as number)); srcX = Math.round((width - cropW) / 2); } else { cropH = Math.round(width / (ratioWH as number)); srcY = Math.round((height - cropH) / 2); } const cropSurface = createSurface(cropW, cropH); if (!cropSurface) { console.error('Failed to create aspect-crop surface'); return null; } own(cropSurface); const cropCanvas = cropSurface.getCanvas(); cropCanvas.drawImageRect( skImage, Skia.XYWHRect(srcX, srcY, cropW, cropH), Skia.XYWHRect(0, 0, cropW, cropH), own(Skia.Paint()) ); const cropped = cropSurface.makeImageSnapshot(); if (!cropped) { console.error('Failed to snapshot aspect crop'); return null; } own(cropped); // Pre-crop image and the crop surface: both superseded, only the // snapshot enters the render pass. release(owned, skImage); release(owned, cropSurface); skImage = cropped; width = cropW; height = cropH; } // 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; } own(surface); const canvas = surface.getCanvas(); const paint = own(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 = own(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. // The stock's own tone pass (Classic Neg split tone, Classic Chrome shadow // crush) rides along here, so those two recipes export with the pass even // when every user knob is 0. const tone = getToneUniforms(adjustments, recipe.baseFilter); const hasTone = toneIsActive(tone); let toneShader: SkShader | null = null; if (hasTone) { let toneEffect: ReturnType = null; try { toneEffect = own(Skia.RuntimeEffect.Make(TONE_SKSL)); } catch (e) { console.error('TONE_SKSL compile failed - falling back to matrix-only: ' + e); } if (toneEffect) { const imageShader = own( skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None) ); toneShader = own(toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShader])); if (toneShader) { paint.setShader(toneShader); } else { console.error('Tone shader build failed - falling back to matrix-only'); } } else { console.error('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 = own(Skia.RuntimeEffect.Make(CINEMA_SKSL)); if (cinemaEffect) { const child = toneShader ?? own(skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None)); const cs = own(cinemaEffect.makeShaderWithChildren(cinema.flat, [child])); if (cs) { paintShader = cs; paint.setShader(paintShader); } } } catch (e) { console.error('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 = own(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 = own(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 = own(mistFilter); } } // Negative SHARPENING is the amount of digital edge enhancement to take // back OUT. It runs HERE, inside the photo's own filter chain, because the // post-grain twin (step 9) blurred the grain overlay away: NR < 0 plus // SHARPENING < 0 exported as flat mush with no grain at all. const sharpKnob = adjustments.sharpening ?? 0; if (sharpKnob < 0) { const softenSigma = (Math.abs(sharpKnob) / 10) * 3; const softenFilter = Skia.ImageFilter.MakeBlur( softenSigma, softenSigma, TileMode.Clamp, imageFilter ); if (softenFilter) { imageFilter = own(softenFilter); } } 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); } // 5b. Highlight Diffusion Filter (HDF): bloom on the bright areas only — // the software twin of the lens' built-in diffusion filter, Photoshopped // exactly as described: extract the highlights with a Bright Pass Filter on // the LUMINANCE, Gaussian-blur that selection (radius 15..50px), Screen it // back over the photo at reduced opacity. Below the knee the pass returns // exact 0.0 and Screen against black is a no-op, so the shadows stay where // they were while only the lit areas spread their light. // One 0..10 knob drives both the blur radius and the opacity, and the radius // is a fraction of the width so any source size blooms alike. // ponytail: split into radius/amount knobs only if the 1D ramp reads coarse. if ((adjustments.hdf ?? 0) > 0) { const strength = (adjustments.hdf ?? 0) / 10; const sigma = width * (0.004 + 0.015 * strength); // ~10..50px at 2560 wide // Bright pass by LUMINANCE (see GLOW_SKSL). The old per-channel matrix let // a saturated blue through untinted and left a coloured smear in the // darks; one luma knee + one gain kills both. let glowEffect: ReturnType = null; try { glowEffect = own(Skia.RuntimeEffect.Make(GLOW_SKSL)); } catch (e) { console.error('GLOW_SKSL compile failed - per-channel fallback: ' + e); } const srcShader = paintShader ?? own(skImage.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.None)); const glowShader = glowEffect != null ? own(glowEffect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null; const glowPaint = own(Skia.Paint()); glowPaint.setBlendMode(BlendMode.Screen); glowPaint.setAlphaf(0.15 + 0.35 * strength); if (glowShader != null) { glowPaint.setShader(glowShader); // Recipe matrix stays where it always was — inside the filter chain, // under the blur (inner runs first) — so the bloom lands on GRADED // pixels: a monochrome recipe glows white instead of leaking the // original colours back through the highlights. glowPaint.setImageFilter( own( Skia.ImageFilter.MakeCompose( own(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)), own(Skia.ImageFilter.MakeColorFilter(colorFilter, null)) ) ) ); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint); } else { // GLOW_SKSL unavailable: keep the old per-channel curve (composed on TOP // of the recipe matrix, the order that keeps a monochrome look white). // A leaky bloom beats no bloom at all. const hiMatrix = [ 2.5, 0, 0, 0, -1.5, 0, 2.5, 0, 0, -1.5, 0, 0, 2.5, 0, -1.5, 0, 0, 0, 1, 0, ]; glowPaint.setImageFilter( own( Skia.ImageFilter.MakeCompose( own(Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null)), own( Skia.ImageFilter.MakeColorFilter( own( Skia.ColorFilter.MakeCompose( own(Skia.ColorFilter.MakeMatrix(hiMatrix)), colorFilter ) ), null ) ) ) ) ); if (paintShader) { glowPaint.setShader(paintShader); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint); } else { canvas.drawImage(skImage, 0, 0, glowPaint); } } } // 6. Add Monochrome Grain Overlay // NOISE REDUCTION below 0 works the other way round: it hands back the grain // the denoiser would have flattened (a JPEG cannot be un-smoothed, but it // can be re-grained). Same overlay as GRAIN, capped at the GRAIN slider's // own ceiling so a negative NR plus GRAIN never double up. // ponytail: re-grain, not de-noise; add a real grain-preserving NR the day // the pipeline gets a pre-ISP raw frame. const grainAmount = Math.min( 10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0)) ); if (grainAmount > 0) { const grainOpacity = grainAmount / 20; // up to 0.5 opacity const grainPaint = own(Skia.Paint()); grainPaint.setBlendMode(BlendMode.Overlay); grainPaint.setAlphaf(grainOpacity); // Procedural noise shader — literally the preview's, and the web build's // (see grainShader.ts). The cell is the picture's own width / GRAIN_REF, so // the grain is the same size RELATIVE to the picture as the preview that // tuned it; hashing the raw pixel grid made a 4000px file's grain ~4x // finer than the grain the user had just tuned at 1080px. const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL)); const noiseShader = noiseEffect ? own( noiseEffect.makeShader([ grainCell(width, 1, adjustments.grainSize ?? 100), 1, GRAIN_SEED[0], GRAIN_SEED[1], ]) ) : null; if (noiseShader) { grainPaint.setShader(noiseShader); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint); } } // 6b. Vignetting: darken the four corners of the PHOTO. Radial gradient // (clear through the mid-frame, black at the corners) whose radius is the // photo diagonal/2, so the falloff stays circular on any aspect. Runs // before the frame step, so on a polaroid/wall export the darkening lands // on the picture inside the opening, never on the mat. // ponytail: one amount knob (the Fuji-style VIGNETTING control); split into // amount + radius/midpoint only if users ask to move the falloff. if ((adjustments.vignette ?? 0) > 0) { const v = Math.min(10, adjustments.vignette ?? 0) / 10; const vignetteShader = own(Skia.Shader.MakeRadialGradient( Skia.Point(width / 2, height / 2), Math.hypot(width, height) / 2, [Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')], [0, 0.45, 1], TileMode.Clamp )); if (vignetteShader) { const vignettePaint = own(Skia.Paint()); vignettePaint.setShader(vignetteShader); vignettePaint.setAlphaf(0.9 * v); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint); } } // Every pass so far sampled skImage; the frame, snapshot, sharpen and // encode steps work off the render surface only, so the decoded source // (48MB at 12MP) can go before they allocate their own buffers. release(owned, skImage); // 7. Frame. RETRO INSTANT 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. WALL FRAME: the export IS the frame PNG at its native size — // portrait (rotated 90° CW) or landscape, per the FRAME tab's ORIENTATION // chip — with the photo cover-cropped // (center) into the fixed 2:3 window (the mat opening of a real frame). // 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; // Card/frame branches copy the processed photo into a surface of their // own; from that copy on the 12MP source surface and its snapshot are // dead, so they are released below instead of at the finally block. let cardSource: SkImage | null = null; if (frameId === 'polaroid') { const photoImg = own(surface.makeImageSnapshot()); cardSource = photoImg; const outW = width; // card width matches the photo width // Card proportions follow the UN-rotated source, so rotating the print // inside the frame never resizes the frame (same rule as the viewfinder). const frameAspect = ratioWH ?? (options?.photoRotation === 90 || options?.photoRotation === 270 ? height / width : width / height); const outH = Math.round( width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + (width * POLAROID_WIN_W) / frameAspect ); const cardSurface = createSurface(outW, outH); if (!cardSurface) { console.error('Failed to create polaroid card surface'); return null; } own(cardSurface); const cardCanvas = cardSurface.getCanvas(); const bgPaint = own(Skia.Paint()); bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint); const pl = polaroidLayout(outW, outH, frameAspect, 'contain'); const win = pl.window; // Window aspect == the un-rotated photo aspect → an un-rotated export // fills it exactly; a rotated print is contain-fitted (centered, // letterboxed) inside it, never stretched. // With a framed-window zoom the source rect shrinks to the visible part // (window keeps showing the same geometry as the viewfinder). const plainPaint = own(Skia.Paint()); const pz = options?.frameWindowZoom ?? null; const pwz = pz && pz.s > 1.001 ? pz : null; const pVisW = pwz ? width / pwz.s : width; const pVisH = pwz ? height / pwz.s : height; const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0; const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0; const srcAspect = width / height; let dstW = win.w; let dstH = dstW / srcAspect; if (dstH > win.h) { dstH = win.h; dstW = dstH * srcAspect; } cardCanvas.drawImageRect( photoImg, Skia.XYWHRect(pSx, pSy, pVisW, pVisH), Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH), plainPaint ); // Thin seam separating image window from the card. const seamPaint = own(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; } else if (frameId === 'wallframe') { // Export at the ROTATED frame's native size (the frame is the product, so // the file is exactly frame-sized — never the photo's own dimensions). const photoImg = own(surface.makeImageSnapshot()); cardSource = photoImg; const pw = photoImg.width(); const ph = photoImg.height(); const wallLand = options?.wallframeLandscape === true; const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W; // 3117 / 4000 const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H; // 4000 / 3117 const cardSurface = createSurface(outW, outH); if (!cardSurface) { console.error('Failed to create wallframe surface'); return null; } own(cardSurface); const cardCanvas = cardSurface.getCanvas(); // Decode the frame PNG artwork (bundled asset → base64, same path as the // GPS fonts below — embedded assets may not expose a readable file URI). const wfAsset = Asset.fromModule(require('../../wallframe.png')); // Android release builds resolve an image asset to a drawable *name* // ('wallframe', no scheme) and mark it downloaded, so downloadAsync() // short-circuits and readAsStringAsync throws 'Unsupported scheme for // location wallframe'. Clear that state so the native module copies the // drawable out of the APK into the cache as a real file path. wfAsset.localUri = null; wfAsset.downloaded = false; await wfAsset.downloadAsync(); const wfDataStr = wfAsset.localUri ? await FileSystem.readAsStringAsync(wfAsset.localUri, { encoding: FileSystem.EncodingType.Base64 }) : null; if (!wfDataStr) { console.error('Failed to load wallframe.png data'); return null; } const wfData = Skia.Data.fromBase64(wfDataStr); own(wfData); const wfImage = Skia.Image.MakeImageFromEncoded(wfData); if (!wfImage) { console.error('Failed to decode wallframe.png'); return null; } own(wfImage); const wl = wallframeLayout(outW, outH, 'fill', wallLand); const win = wl.window; // Cover-crop (center): scale the processed photo so it fills the fixed // 3:2 window and cut the overflowing edges equally on both sides. const plainPaint = own(Skia.Paint()); const scale = Math.max(win.w / pw, win.h / ph); const srcW = win.w / scale; const srcH = win.h / scale; // Cover plane (center crop) that fills the window; a framed-window zoom // shrinks the visible source sub-rect inside that plane. const planeX = (pw - srcW) / 2; const planeY = (ph - srcH) / 2; const wz = options?.frameWindowZoom ?? null; const wZoom = wz && wz.s > 1.001 ? wz : null; const wVisW = wZoom ? srcW / wZoom.s : srcW; const wVisH = wZoom ? srcH / wZoom.s : srcH; const wSx = wZoom ? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW) : planeX; const wSy = wZoom ? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH) : planeY; cardCanvas.drawImageRect( photoImg, Skia.XYWHRect(wSx, wSy, wVisW, wVisH), Skia.XYWHRect(win.x, win.y, win.w, win.h), plainPaint ); // Artwork over the crop: its transparent window reveals the photo, the // mat covers the rest. The source PNG is landscape — rotate it 90° CW // onto the portrait canvas (same affine as wallframeLayout.drawMatrix). cardCanvas.save(); if (wallLand) { // Artwork is already landscape: outW/outH ARE its own size. cardCanvas.drawImage(wfImage, 0, 0, plainPaint); } else { cardCanvas.translate(outW, 0); cardCanvas.rotate(90, 0, 0); cardCanvas.drawImage(wfImage, 0, 0, plainPaint); } cardCanvas.restore(); outputSurface = cardSurface; stampCanvas = cardCanvas; stampW = outW; stampH = outH; } else { drawFrameOnCanvas(canvas, width, height, frameId); } // The photo now lives inside outputSurface: drop the source surface and // its snapshot before the snapshot/sharpen/encode steps allocate their // own full-res buffers (~96MB less at the peak of the render). if (cardSource) { release(owned, cardSource); release(owned, surface); } // 8. Render GPS Geotag Watermark + Custom Text Watermark. One asset load + // one amber-mono paint path serves both: GPS keeps its two icon lines; the // custom watermark is plain text drawn at the user-dragged position. const customWm = options?.watermark ?? null; const liteWm = options?.liteMark ?? null; if ((useGeotag && gpsInfo) || customWm || liteWm) { // Faces are process-cached (see loadWatermarkFonts) and must NOT be // disposed here — the next export reuses them. const fonts = await loadWatermarkFonts(); const typeface = fonts?.typeface; const iconTypeface = fonts?.iconTypeface; if (typeface && iconTypeface) { const fontSize = Math.round(stampW * 0.032); // 3.2% of output width const textPaint = own(Skia.Paint()); textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500 const iconPaint = own(Skia.Paint()); iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text // GPS block: its TOP-LEFT corner sits at the dragged fraction of the // same area the viewfinder maps onto (a framed export's canvas IS the // card/frame), so the preview and the file agree. Defaults = the old // fixed top-left look. if (useGeotag && gpsInfo) { 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 gpsPos = options?.gpsWatermark ?? null; const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1)); const gpsFont = own(Skia.Font(typeface, gpsSize)); const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize)); const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em) const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW; const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH; // Each enabled line (place name / shot time) stacks under the // previous one, exactly as the viewfinder draws it. const lines: { icon: string; text: string }[] = []; if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName }); if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr }); textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b')); // user-chosen, amber by default // Spin the whole block around its top-left (the anchor the // viewfinder uses). 0 = the historical upright look. const wmRot = options?.watermarkRotation ?? 0; stampCanvas.save(); if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY); lines.forEach((line, i) => { const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2)); stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont); stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont); }); stampCanvas.restore(); } // Custom watermark: baseline at the dragged fraction of the photo/window // area + ~0.35em drop (the grabbed anchor reads as the text's visual // center, not its baseline) — the exact formula Viewfinder uses, so // preview == export. if (customWm || liteWm) { // The mark lives on the FRAME: a framed export's canvas IS the // card/frame (see the branches above), so the whole stamp is the // area — the same 0..1 fractions the viewfinder drag produced. const area = { dx: 0, dy: 0, dw: stampW, dh: stampH }; const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); const wmRot = options?.watermarkRotation ?? 0; // Both marks share this path: the user's text (free in every tier) // and, in LITE, the build's own RECIPESCAM mark next to it. for (const mark of [customWm, liteWm]) { if (!mark) continue; const wmSize = Math.round(fontSize * (mark.size ?? 1)); // The system FontMgr itself is a long-lived singleton — only the // matched face is ours to release. const wmFace = mark.font ? own(Skia.FontMgr.System().matchFamilyStyle(mark.font, FontStyle.Normal)) : null; const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize)); const wmPaint = own(Skia.Paint()); wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b')); const tx = area.dx + clamp01(mark.x) * area.dw; const ty = area.dy + clamp01(mark.y) * area.dh + Math.round(wmSize * 0.35); stampCanvas.save(); if (wmRot) stampCanvas.rotate(wmRot, tx, ty); stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont); stampCanvas.restore(); } } } } // 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip) let resultImage = own(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. // A negative knob means "no sharpening at all" (its blur already ran at // step 4, under the grain), so the legacy 0.5 fallback must not fire. const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0; if (sharpenAmount > 0) { const sharpened = screenSharpenImage(resultImage, sharpenAmount); // The helper returns its own input when it cannot allocate a surface. if (sharpened && sharpened !== resultImage) { own(sharpened); // The unsharpened copy is superseded before the encode: freeing it // here keeps one less 12MP buffer alive across the JPEG readback. release(owned, resultImage); resultImage = sharpened; } } // 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; } // 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); } // 9c. EXIF. Skia's re-encode dropped the loaded photo's metadata block, so // rebuild it here: the source photo donates every tag this app does not // own (camera, lens, exposure) and the fields it is responsible for are // stamped on top — software + version, capture date, GPS, pixel size. bytes = writeJpegExif( bytes, { dateTime: options?.capturedAt ? new Date(options.capturedAt) : null, gps: gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null, width: resultImage.width(), height: resultImage.height(), dpi: options?.dpi ?? null, locationName: gpsInfo ? gpsInfo.locality : null, device: getDeviceIdentity(), }, await readPhotoBytes(sourceUri) ); // 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 (and the // gallery shows this basename). const exportFile = nextPhotoPath(options?.exported === true); try { new File(exportFile).write(bytes); } catch (e) { console.error('Failed to write export file: ' + e); return null; } // 11. Save to the device gallery. The native module is a plain MediaStore // insert into DCIM/Camera and needs NO runtime permission, so it runs // first; expo-media-library (which can only reach DCIM root or // Pictures/Camera) is the fallback. Order is load-bearing: asking for // READ_MEDIA_IMAGES raises the OS permission dialog, and a dialog on top // of the live viewfinder takes the camera session down with it — the // export stalled 9-16s and the next tap answered "Capture Failed". try { const assetId = await RecipescamExport.saveToCameraRollAsync(stripScheme(exportFile), 'image/jpeg'); if (assetId) { return { uri: exportFile, savedToLibrary: true, assetId }; } } catch (e) { console.warn('Native DCIM/Camera save failed, falling back to MediaLibrary:', e); } // Fallback only: the permission is asked for here, off the capture path. 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 photo processing and export:', error); return null; } finally { // Only the file URI escapes this function, so every buffer above is dead by // now — including on the early returns. disposeAll(owned); } }