Files
RecipesCam/src/utils/exportEngine.ts
T
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.

A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.

SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.

Measured:
  grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
    grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
    moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
    10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
    522/INCH (66.56: under the preview's pixel floor what is printed is static,
    not finer grain); the region claim on an 8x8 grid of the flat frame gives
    tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
    patch is not a brighter one.
  grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
    module itself: 200% doubles the cell, 50% halves it, the output pixel still
    floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
    preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
    harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
    like every other caller.
  _grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
    1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
    zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
  _grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
    classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
    swing forced to 0, peak/median 32.3 against 27.6.
  _grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
    spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
  grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
    temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
    selectors moved to the strip).
  tsc: web clean; the phone's scoped config reports exactly the pre-change
    baseline (Viewfinder.tsx's own errors, none new).

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00

989 lines
44 KiB
TypeScript

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_<ts>_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<ExportResult | null> {
// Released in the finally below — see SkDisposable.
const owned: SkDisposable[] = [];
// accepts possibly-null factory results so the call sites stay one-liners
const own = <T extends SkDisposable>(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<typeof Skia.RuntimeEffect.Make> = 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<typeof Skia.RuntimeEffect.Make> = 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 <Image> 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);
}
}