Files
RecipesCam/docker/frontend/src/engine/exportEngine.ts
T
3dtours 8d0a46e7f5 fix(frame): give OLD FILM PORTRAIT a real portrait output
The sheet was stretched over the visitor's frame, so the portrait variant
only turned the paper inside whatever shape the photo had: a landscape
photo stayed landscape. The film now has its own opening like the walls
do — the frame is scaled up to the photo, the photo is cover-cropped into
the whole sheet, and `old-film-portrait` is the PNG turned 90° CW, so the
pair is one frame standing and one lying. The torn edge has no straight
sides, so the photo runs under all of it instead of being cut against a
measured window.
2026-10-02 19:54:42 +07:00

1685 lines
71 KiB
TypeScript

// Full-resolution render pipeline for the web build.
//
// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit,
// step for step, so the file a browser downloads matches the file the phone
// writes. What changed and why (W0 probe, measured — see skiaShim.ts header):
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
// gone (the browser downloads a Blob instead).
// - `MakeMatrixConvolution` does not exist in CanvasKit. The output sharpen
// (and CLARITY's own kernel) run through shader passes instead —
// `SHARPEN_SKSL` for the knob, the same kernel with the doc's Sobel edge
// mask and detail coring on it. `convolve` replaces the pixels outright (the
// shader's output is opaque and covers the same rect), so no clearing pass
// is needed.
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
// be composited over the un-blurred content, so those passes snapshot, clear
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
// mist → soften. NOISE REDUCTION's chroma reference is one of those blurs,
// and only its chroma (NR_SKSL) — the knob used to smear all three channels.
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
// alone keeps the print size metadata.
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
import { Skia } from './skiaShim';
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types';
import { ASPECT_RATIO_W_H } from '../../shared/types';
import { getSkiaColorMatrix, exposureStops } from '../../shared/utils/colorUtils';
import {
TONE_SKSL,
EXPOSURE_SKSL,
GLOW_SKSL,
SHARPEN_SKSL,
CLARITY_BLUR_SKSL,
CLARITY_BLEND_SKSL,
NR_SKSL,
NR_CHROMA_SPAN,
DEHAZE_SKSL,
dehazeUniformArray,
getToneUniforms,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneIsActive,
toneUniformArray,
glowUniformArray,
} from '../../shared/utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader';
import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve';
import { healSkSL, healUniforms, readHeal } from '../../shared/utils/heal';
import { mosaicSkSL, mosaicUniforms, readMosaic } from '../../shared/utils/mosaic';
import { gradientMaskSkSL, maskUniforms, readMasks, masksHaveSpatial } from '../../shared/utils/gradientMask';
import {
GRAIN_SKSL,
HALATION_SKSL,
HALATION_MAX,
grainCell,
grainStockFor,
grainUniformArray,
halationSigma,
halationUniformArray,
} from '../../shared/utils/grainShader';
import {
drawFrameOnCanvas,
polaroidLayout,
POLAROID_CARD,
POLAROID_WIN_W,
wallframeLayout,
WALLFRAME_W,
WALLFRAME_H,
WALLFRAME_LAND_W,
WALLFRAME_LAND_H,
} from '../../shared/utils/frameUtils';
import { patchJpegDpi } from '../../shared/utils/jpegDpi';
import { applyPhotoRotation } from '../../shared/utils/skiaImage';
export interface RenderAssets {
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
wallframe: Uint8Array; // wallframe.png
oldFilm: Uint8Array; // old_film.png
// The FONT option's faces beside DEFAULT (see assets.ts MARK_FACES).
markFonts: { family: string; bytes: ArrayBuffer }[];
}
export interface RenderOptions {
dpi?: number;
sharpen?: boolean;
evFromCamera?: boolean;
aspect?: AspectRatio;
cropWH?: number;
cropRect?: CropRect | null;
watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
watermarkRotation?: number;
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean; font?: string | null } | null;
wallframeLandscape?: boolean;
frameWindowZoom?: { s: number; u: number; v: number } | null;
photoRotation?: 0 | 90 | 180 | 270;
photoStraighten?: number;
// FRAME-tab mirrors: left<->right and top<->bottom, each independent of the
// quarter turn and the fine angle (they ride on top of both).
photoFlipH?: boolean;
photoFlipV?: boolean;
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
// band appended below it, mirroring the landing page's `.lp-shot` +
// `.lp-frame-meta`. Left off the live preview, which must not show it.
caption?: { tag: string; title: string; meta: string } | null;
}
export interface RenderInput {
source?: Uint8Array;
recipe: Recipe;
frameId: FrameId;
useGeotag?: boolean;
gpsInfo?: GPSInfo | null;
options?: RenderOptions;
assets: RenderAssets;
// A picture the caller already decoded, instead of `source`'s bytes. The live
// camera has a new one every frame: re-encoding it to JPEG only for this to
// decode it again would cost more than the whole render.
sourceImage?: any;
// Paint the finished picture instead of encoding it, and hand it over here.
// The live view blits it and lets it go; the bytes of a JPEG would be thrown
// away thirty times a second. Called synchronously, with an image that is
// already dead once this returns.
drawTo?: (image: any) => void;
}
export interface RenderResult {
bytes: Uint8Array;
width: number;
height: number;
}
type SkDisposable = { dispose(): void };
function disposeAll(items: SkDisposable[]): void {
for (let i = items.length - 1; i >= 0; i--) {
try {
items[i].dispose();
} catch {
/* already released */
}
}
}
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 createSurface(width: number, height: number) {
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
}
// A WebGL surface batches draws; the readback paths (snapshot, encode) must see
// them submitted first. A no-op on the raster fallback.
function flush(surface: any): void {
if (typeof surface?.flush === 'function') surface.flush();
}
// --- shared runtime effects -------------------------------------------------
let sharpenEffect: any = null;
let toneEffect: any = null;
let exposureEffect: any = null;
let cinemaEffect: any = null;
let glowEffect: any = null;
let halationEffect: any = null;
let curveEffect: any = null;
let clarityBlurEffect: any = null;
let clarityBlendEffect: any = null;
let nrEffect: any = null;
let dehazeEffect: any = null;
function effects() {
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(SHARPEN_SKSL);
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
if (!exposureEffect) exposureEffect = Skia.RuntimeEffect.Make(EXPOSURE_SKSL);
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
return {
sharpenEffect,
toneEffect,
exposureEffect,
cinemaEffect,
glowEffect,
halationEffect,
curveEffect,
clarityBlurEffect,
clarityBlendEffect,
nrEffect,
dehazeEffect,
};
}
// FX's HEAL is the one effect whose shader depends on the recipe rather than on
// constants: it carries exactly the spots the photo has (shared/utils/heal.ts),
// which is what lets the list be uncapped. Every distinct count is therefore a
// shader of its own, kept here so a stroke's worth of spots is paid for once
// and every later render at that count is free.
const healEffects = new Map<number, any>();
function healEffectFor(count: number): any {
let effect = healEffects.get(count);
if (effect === undefined) {
effect = Skia.RuntimeEffect.Make(healSkSL(count)) ?? null;
healEffects.set(count, effect);
}
return effect;
}
// FX's MOSAIC is the same kind of shader — one unrolled block per spot the
// recipe holds (shared/utils/mosaic.ts) — so it is cached by count the same way.
const mosaicEffects = new Map<number, any>();
function mosaicEffectFor(count: number): any {
let effect = mosaicEffects.get(count);
if (effect === undefined) {
effect = Skia.RuntimeEffect.Make(mosaicSkSL(count)) ?? null;
mosaicEffects.set(count, effect);
}
return effect;
}
// FX's gradient masks are the third shader of that kind — the shapes and the
// knobs are the recipe's, so it is built for the count it is handed and cached
// by count the same way (shared/utils/gradientMask.ts). Two variants per count:
// the masks that carry CLARITY or DEHAZE read a second child (the frame's own
// blurred reference), the rest do not, and neither pays for the other.
const maskEffects = new Map<string, any>();
function maskEffectFor(count: number, spatial: boolean): any {
const key = `${count}:${spatial ? 1 : 0}`;
let effect = maskEffects.get(key);
if (effect === undefined) {
effect = Skia.RuntimeEffect.Make(gradientMaskSkSL(count, spatial)) ?? null;
maskEffects.set(key, effect);
}
return effect;
}
// SHARPEN_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
// a 1:1 export canvas, so the radius matches what the preview tuned.
function convolvePaint(srcImage: any, amount: number): any {
const { sharpenEffect: effect } = effects();
if (!effect) return null;
const paint = Skia.Paint();
const child = srcImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]);
if (!shader) {
paint.dispose();
child.dispose();
return null;
}
paint.setShader(shader);
return { paint, shader, child };
}
// Replaces the pixels under the rect with the convolved result.
function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
const built = convolvePaint(srcImage, amount);
if (!built) return;
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint);
disposeAll([built.paint, built.shader, built.child]);
}
function convolveImage(image: any, amount: number): any {
const w = image.width();
const h = image.height();
const surf = createSurface(w, h);
if (!surf) return image;
try {
drawConvolved(surf.getCanvas(), image, w, h, amount);
return surf.makeImageSnapshot() ?? image;
} finally {
surf.dispose();
}
}
function screenSharpenImage(image: any, amount = 0.5): any {
return convolveImage(image, amount);
}
// The colour matrix, applied on its own into a fresh image. A SkPaint runs its
// shader before its colourFilter, so the graded pixels have to exist as an
// image before the tone/cinema pass can read them (see renderPhoto step 3).
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const p = Skia.Paint();
p.setColorFilter(colorFilter);
surf.getCanvas().drawImage(image, 0, 0, p);
p.dispose();
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
// pixels it is meant to replace.
function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
flush(surface);
const snap = surface.makeImageSnapshot();
const paint = Skia.Paint();
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
paint.setImageFilter(filter);
canvas.clear(Skia.Color('transparent'));
canvas.drawImage(snap, 0, 0, paint);
disposeAll([paint, filter, snap]);
}
// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as
// an image the tone shader takes as its second child and reads once per pixel.
// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the
// sharpening pass — because what the ramp needs is an AVERAGE of the
// neighbourhood and a ring of point samples is not one: sampled on a fine ring
// the luma aliases, the gain o(base)/base inherits the alias, and the
// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came
// out with 0.063 of high-frequency gain against 0.005 for this blur — see
// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the
// tone pass reads the same shader as its first child.
function blurredBase(
shaderOf: () => any,
w: number,
h: number,
sigma: number
): any | null {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const shader = shaderOf();
if (!shader) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(shader);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
// A blurred copy of what the surface holds right now, as an image a pass can
// take as a child. Same `MakeBlur` as the ramp's base and the negative blurs,
// but built from a SNAPSHOT rather than from a shader the caller still holds —
// so the snapshot's own shader is freed here, and NOISE REDUCTION can read the
// graded pixels of the frame without re-deriving the draw that made them.
function blurredFrame(surface: any, w: number, h: number, sigma: number): any | null {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return null;
const surf = createSurface(w, h);
const child = surf ? imageShaderChild(snap) : null;
try {
if (!surf || !child) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(child);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
return surf.makeImageSnapshot() ?? null;
} finally {
if (child) child.dispose();
surf?.dispose();
snap.dispose();
}
}
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
//
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
// ki_n_tr_c_multiple_passes... entirely) compare a pixel against a blurred
// reference of its own surroundings. The reference is built once here, the doc's
// way: a separable pair of passes — 1x15 across, 15x1 down — writes two offscreen
// surfaces the screen never shows, so a pixel costs 30 reads instead of the 225
// one 15x15 kernel would take. Offscreen surfaces rather than the doc's
// `rgba16float` textures because this renderer is 8-bit end to end (CanvasKit
// surfaces, see skiaShim.ts); the format is the only thing the doc and this file
// disagree about.
// How far out the reference looks, as a fraction of the frame's width, so the
// same CLARITY is the same size of neighbourhood on the preview and on the file.
const CLARITY_BLUR_SPAN = 0.06;
// One axis of the reference. `image` is the input, the result is a new image the
// caller owns.
function blurAxis(image: any, w: number, h: number, dx: number, dy: number): any | null {
const { clarityBlurEffect: effect } = effects();
const surf = createSurface(w, h);
if (!surf || !effect) {
surf?.dispose();
return null;
}
let child: any = null;
try {
child = image.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
const shader = child ? effect.makeShaderWithChildren([dx, dy], [child]) : null;
if (!shader) return null;
const paint = Skia.Paint();
paint.setShader(shader);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, shader]);
return surf.makeImageSnapshot() ?? null;
} finally {
if (child) disposeAll([child]);
surf.dispose();
}
}
// The frame's own blurred reference, B in both docs: what the pixel is compared
// against for CLARITY, and the patch average the dark channel is read out of for
// DEHAZE. Null only if the effect could not be built.
function spatialReference(surface: any, w: number, h: number): any | null {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return null;
try {
const step = Math.max(1, (w * CLARITY_BLUR_SPAN) / 15);
const horizontal = blurAxis(snap, w, h, step, 0);
if (!horizontal) return null;
try {
return blurAxis(horizontal, w, h, 0, step);
} finally {
horizontal.dispose();
}
} finally {
snap.dispose();
}
}
// The doc's atmospheric light A, the "amount of scattered light" DEHAZE takes
// back out. Read off the frame the pass is about to correct, through one 32x32
// draw rather than the histogram the doc describes: the Dark Channel Prior's own
// answer is the pixel whose DARKEST channel is brightest, so the 4096-pixel copy
// only has to be searched for that one. Null when the readback is unavailable —
// the caller then leaves the pass out rather than guessing a value.
// Where a pixel stops being evidence of A, and the share of the survivors that
// speaks for it (see the note inside).
const DEHAZE_AIR_BLOWN = 0.9;
const DEHAZE_AIR_FRACTION = 0.02;
function atmosphericLight(surface: any, w: number, h: number): [number, number, number] | null {
const n = 32;
const small = createSurface(n, n);
if (!small) return null;
let snap: any = null;
let paint: any = null;
try {
flush(surface);
snap = surface.makeImageSnapshot();
if (!snap) return null;
paint = Skia.Paint();
small.getCanvas().drawImageRect(
snap,
Skia.XYWHRect(0, 0, w, h),
Skia.XYWHRect(0, 0, n, n),
paint
);
flush(small);
const px = small.getCanvas().readPixels(0, 0, {
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
// A blown pixel's dark channel is 1 whatever the haze is — white cloth the
// camera clipped reads exactly like white haze — so a frame with a white
// anything in it answered A = [1, 1, 1]. That is the worst A to hand
// `J = (I - A)/t + A`: every pixel below 1 goes negative on some channel and
// is clipped back channel by channel, which is the lilac cast on white cloth
// and the bright rim around every dark detail (one pixel over, the patch
// holds a shadow and t is 1 again).
//
// So the pixels that can be evidence of the light are the ones short of
// white, and A is the mean of the brightest of those — the doc's 0.1% is one
// pixel of this 32x32 copy, which a single specular dot would own.
const evidence: Array<[number, number, number]> = [];
for (let i = 0; i < n * n; i++) {
const rgb: [number, number, number] = [
px[i * 4] / 255,
px[i * 4 + 1] / 255,
px[i * 4 + 2] / 255,
];
if (Math.min(rgb[0], rgb[1], rgb[2]) < DEHAZE_AIR_BLOWN) evidence.push(rgb);
}
// A frame with nothing short of white in it is all haze: the doc's answer
// would be A = 1 and the inversion would be all clipping, so DEHAZE stays off
// instead (the same null the readback failure returns).
if (evidence.length < 2) return null;
evidence.sort((l, r) => Math.min(r[0], r[1], r[2]) - Math.min(l[0], l[1], l[2]));
const keep = Math.max(1, Math.round(evidence.length * DEHAZE_AIR_FRACTION));
const air: [number, number, number] = [0, 0, 0];
for (let i = 0; i < keep; i++) {
air[0] += evidence[i][0];
air[1] += evidence[i][1];
air[2] += evidence[i][2];
}
return [air[0] / keep, air[1] / keep, air[2] / keep];
} catch {
// No readback (a surface that refuses one, a shader that failed): DEHAZE
// stays off for this render.
return null;
} finally {
disposeAll([paint, snap].filter(Boolean) as SkDisposable[]);
small.dispose();
}
}
// DEHAZE's dark channel, the prior's own quantity, built as an image for
// DEHAZE_SKSL to sample — the pass itself turns it into t.
//
// The pass used to take the dark channel's minimum inline, over five taps spaced
// 0.625% of the frame's width apart — one tap every 7 pixels of a 1067-pixel
// preview. A dark detail thinner than that spacing sat between two taps on one
// row of the frame and under a tap on the next, so t swung between "this patch
// holds a shadow, leave it alone" and "this patch is all haze, divide hard" with
// a 7-pixel period around every dark thing in the photo. A rising DEHAZE drew
// that period as bright copies of the detail, stacked beside it.
//
// Reading the dark channel off a small copy of the frame is what removes it: one
// cell of that copy already covers a whole patch, so the neighbourhood is
// covered rather than sampled, and the bilinear upscale interpolates it back with
// no period left in it. The box passes after that are the doc's soft matting in
// the one form that is free here — the map is smoothed, not the pixels.
//
// The dark channel and not t is what goes into that image, because a channel is
// eight bits and only carries 0..1: t is 1 + 0.95 at the negative end of the
// knob, and would arrive clipped to 1, turning "scatter the light back in" into a
// pass that does nothing at all. The dark channel is 0..1 by construction, and
// the signed amount stays a uniform, where it costs no range.
const DEHAZE_MAP_SIZE = 64;
const DEHAZE_MAP_TAPS = 1; // patch reach, in cells of that copy
const DEHAZE_MAP_BLUR = 1; // box radius, in cells of that copy
// One separable box pass over the copy's own grid.
function boxBlurMap(src: Float32Array, n: number, r: number): Float32Array {
const mid = new Float32Array(n * n);
const out = new Float32Array(n * n);
const edge = (v: number) => Math.min(n - 1, Math.max(0, v));
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let sum = 0;
for (let i = -r; i <= r; i++) sum += src[y * n + edge(x + i)];
mid[y * n + x] = sum / (2 * r + 1);
}
}
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let sum = 0;
for (let j = -r; j <= r; j++) sum += mid[edge(y + j) * n + x];
out[y * n + x] = sum / (2 * r + 1);
}
}
return out;
}
// Null when the copy cannot be read: the caller then leaves DEHAZE out for this
// render rather than correcting with a t it made up (atmosphericLight's own
// answer to the same failure).
function dehazeDarkChannel(
surface: any,
w: number,
h: number,
air: [number, number, number]
): any | null {
const n = DEHAZE_MAP_SIZE;
const small = createSurface(n, n);
if (!small) return null;
let snap: any = null;
let paint: any = null;
try {
flush(surface);
snap = surface.makeImageSnapshot();
if (!snap) return null;
paint = Skia.Paint();
small.getCanvas().drawImageRect(
snap,
Skia.XYWHRect(0, 0, w, h),
Skia.XYWHRect(0, 0, n, n),
paint
);
flush(small);
const px = small.getCanvas().readPixels(0, 0, {
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul,
colorSpace: Skia.ColorSpace.SRGB,
});
if (!px) return null;
const a = [Math.max(air[0], 0.05), Math.max(air[1], 0.05), Math.max(air[2], 0.05)];
const dark = new Float32Array(n * n);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
let d = 1;
for (let j = -DEHAZE_MAP_TAPS; j <= DEHAZE_MAP_TAPS; j++) {
const cy = Math.min(n - 1, Math.max(0, y + j));
for (let i = -DEHAZE_MAP_TAPS; i <= DEHAZE_MAP_TAPS; i++) {
const o = (cy * n + Math.min(n - 1, Math.max(0, x + i))) * 4;
d = Math.min(d, px[o] / 255 / a[0], px[o + 1] / 255 / a[1], px[o + 2] / 255 / a[2]);
}
}
dark[y * n + x] = Math.min(1, Math.max(0, d));
}
}
const smooth = boxBlurMap(boxBlurMap(dark, n, DEHAZE_MAP_BLUR), n, DEHAZE_MAP_BLUR);
// The dark channel rides the red channel and every byte is opaque, so the
// image is its own premultiplied answer and needs no range the shader decodes.
const bytes = new Uint8Array(n * n * 4);
for (let i = 0; i < n * n; i++) {
const v = Math.min(255, Math.max(0, Math.round(smooth[i] * 255)));
bytes[i * 4] = v;
bytes[i * 4 + 1] = v;
bytes[i * 4 + 2] = v;
bytes[i * 4 + 3] = 255;
}
return Skia.Image.MakeImage(
{
width: n,
height: n,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Premul,
},
bytes,
n * 4
);
} catch {
return null;
} finally {
disposeAll([paint, snap].filter(Boolean) as SkDisposable[]);
small.dispose();
}
}
// An image as a child shader — what every effect here wants instead of a paint.
// The caller owns the result (the renderer's `own`).
function imageShaderChild(image: any): any {
return image.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
}
// Snapshot → clear → redraw through the shader the callback builds FROM that
// snapshot: the shape every pass that reads the frame it is about to overwrite
// uses (the negative blurs, the gradient masks, HEAL), in one place so none of
// them has to remember to clear first. The callback owns whatever children it
// puts in the shader; the snapshot is freed here.
function replaceThrough(
canvas: any,
surface: any,
w: number,
h: number,
shaderOf: (snap: any) => any
): void {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return;
try {
const shader = shaderOf(snap);
if (!shader) return;
canvas.clear(Skia.Color('transparent'));
const paint = Skia.Paint();
paint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
disposeAll([paint, shader]);
} finally {
snap.dispose();
}
}
// --- fonts / artwork --------------------------------------------------------
let faces: { typeface: any; iconTypeface: any } | null = null;
let wallframeImage: any = null;
let oldFilmImage: any = null;
function loadFaces(assets: RenderAssets) {
if (faces) return faces;
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont);
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
if (!typeface || !iconTypeface) return null;
faces = { typeface, iconTypeface };
// Named-family lookup for the watermarks' FONT option: register the bundled
// face under the name it is known by, then the faces the FONT strip offers.
try {
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
for (const face of assets.markFonts) {
const named = Skia.Typeface.MakeFreeTypeFaceFromData(face.bytes);
if (named) Skia.FontMgr.System().registerFace(named, face.family);
}
} catch {
/* provider optional */
}
return faces;
}
// The face a mark carrying a named family draws with, or null for the bundled
// one. The stage's drag boxes ask the same question, so they measure with the
// same face the file will print.
function namedFace(font: string | null | undefined) {
return font ? Skia.FontMgr.System().matchFamilyStyle(font) : null;
}
// The face's own numbers, for the boxes the stage draws over a mark: the width
// of the widest line plus the face's ascent/descent, all in pixels at `px`.
// Returns null while the engine is still loading, when no family was picked, or
// when the name is not one the bundle ships — the caller then falls back to the
// monospace em its constants know (ImageStage's WM_ADVANCE_EM and friends).
export function measureMark(
font: string | null | undefined,
px: number,
lines: string[]
): { width: number; ascent: number; descent: number } | null {
if (!Skia || !font || !lines.length) return null;
const face = namedFace(font);
if (!face) return null;
const sized = Skia.Font(face, px);
// Unhinted advances: hinted ones come back rounded per glyph, and a dozen of
// them drift a few pixels off the line they are supposed to bound.
sized.setLinearMetrics?.(true);
try {
const metrics = sized.getMetrics();
let width = 0;
for (const line of lines) {
const advances = sized.getGlyphWidths(line) as unknown as ArrayLike<number>;
let sum = 0;
for (let i = 0; i < advances.length; i++) sum += advances[i];
width = Math.max(width, sum);
}
return { width, ascent: -metrics.ascent, descent: metrics.descent };
} catch {
return null;
} finally {
sized.delete?.();
}
}
// Both walls are one artwork on one code path: `wallframe` hangs the PNG turned
// 90° CW (the portrait opening), `wallframe-landscape` lays it down as it is.
const isWallframe = (id: FrameId) => id === 'wallframe' || id === 'wallframe-landscape';
function loadWallframe(assets: RenderAssets) {
if (wallframeImage) return wallframeImage;
wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe);
return wallframeImage;
}
// Both films are the same sheet of distressed paper: `old-film` lays it down as
// it is, `old-film-portrait` turns it 90° CW, so the pair is one frame standing
// and one lying — the same two shapes the walls offer, in a sheet whose middle
// is a transparent window a couple of percent of paper wide around it.
const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait';
// A frame that carries its own opening: the aspect chip has nothing to say to
// it (both walls, both films).
const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id);
function loadOldFilm(assets: RenderAssets) {
if (oldFilmImage) return oldFilmImage;
oldFilmImage = Skia.Image.MakeImageFromEncoded(assets.oldFilm);
return oldFilmImage;
}
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
const { recipe, frameId, options } = input;
const owned: SkDisposable[] = [];
const own = <T,>(obj: T | null | undefined): T => {
if (obj) owned.push(obj as unknown as SkDisposable);
return obj as T;
};
try {
const skImage0 = input.sourceImage ?? (input.source ? Skia.Image.MakeImageFromEncoded(input.source) : null);
if (!skImage0) {
console.error('Failed to parse source image');
return null;
}
own(skImage0);
let skImage = skImage0;
const rotated = applyPhotoRotation(
skImage,
options?.photoRotation,
options?.photoStraighten,
options?.photoFlipH,
options?.photoFlipV
);
if (rotated && rotated !== skImage) {
own(rotated);
release(owned, skImage);
skImage = rotated;
}
let width = skImage.width();
let height = skImage.height();
// 1b. Composition / FRAME-tab crop — identical maths to the RN engine.
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
const cropRect = options?.cropRect ?? null;
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
// A wall or a film is an artwork with its own opening, so the aspect chip
// (ratioWH) has nothing to say there — but the visitor's own crop does, or a
// photo that was cropped and applied comes back whole behind the frame.
if (cropRect || (ratioWH && !hasOwnAspect(frameId))) {
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) return null;
own(cropSurface);
cropSurface.getCanvas().drawImageRect(
skImage,
Skia.XYWHRect(srcX, srcY, cropW, cropH),
Skia.XYWHRect(0, 0, cropW, cropH),
own(Skia.Paint())
);
const cropped = cropSurface.makeImageSnapshot();
if (!cropped) return null;
own(cropped);
release(owned, skImage);
release(owned, cropSurface);
skImage = cropped;
width = cropW;
height = cropH;
}
// 2. Render surface.
const surface = createSurface(width, height);
if (!surface) return null;
own(surface);
const canvas = surface.getCanvas();
const paint = own(Skia.Paint());
const adjustments = recipe.adjustments;
// 3. Colour matrix, then the exposure pass (+EV unless the camera already
// biased the shot). The matrix grades into its OWN image first, because a
// SkPaint runs its shader before its colourFilter: with both on one paint the
// exposure gain landed AFTER the tone pass, so +EXPOSURE took the 0.78 a
// rolled-back highlight came out at and pushed it straight back to 1.0 — the
// HIGHLIGHT slider looked dead the moment exposure went up (measured, see the
// order probe). The tone shader's child is the graded-and-exposed image, so
// the highlight mask finally sees the value it is meant to pull back.
//
// The gain itself is the linear-light pass of its own (EXPOSURE_SKSL), not a
// row of the matrix: a stop is a multiplier on light and the matrix runs on
// sRGB-encoded values (see colorUtils.exposureStops). It carries the EXPOSURE
// knob, the stock's bias and the EV knob, all in stops.
const userEv = options?.evFromCamera ? 0 : adjustments.exposureCompensation ?? 0;
const evStops = exposureStops(adjustments, recipe.baseFilter) + userEv;
const matrix = getSkiaColorMatrix(recipe.baseFilter, adjustments);
const colorFilter = own(Skia.ColorFilter.MakeMatrix(matrix));
const matrixIsIdentity = matrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
// 3b. Tone shader.
// TONE_BASE_RADIUS of the FRAME is the neighbourhood the ramp is drawn
// through, so the preview and the file look at the same size of one — this is
// the one place that knows how big the frame is. The base itself is a blurred
// copy of the tone shader's own child (blurredBase), not a ring of samples
// inside it: see TONE_BASE_RADIUS for why a ring was the mottle bug. A frame
// too small or a surface that will not come up leaves the sharp child as the
// base, which lands the base on the pixel and keeps the global ramp.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
let toneShader: any = null;
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);
const cinemaOn = !!cinema && cinemaIsActive(cinema.flat);
// 3d. TONE CURVE — the LIGHT tab's graph, applied as the outermost pass (see
// toneCurve.ts). It counts towards `graded` for the same reason the tone
// shader does: the curve reads whatever the matrix left, so when there is a
// matrix to apply it has to be in the pixels the curve's child shader
// samples rather than a colour filter on top of the result.
const curve = adjustments.toneCurve;
const curveOn = curveIsActive(curve);
let paintShader: any = null;
let gradeImage = skImage;
const expOn = evStops !== 0;
const graded = (toneIsActive(tone) || cinemaOn || curveOn || expOn) && !matrixIsIdentity;
if (graded) {
const snapshot = gradeThrough(skImage, width, height, colorFilter);
if (snapshot) {
own(snapshot);
gradeImage = snapshot;
}
}
const imageShaderOf = () =>
own(
gradeImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
);
// Exposure takes the place of the colour filter on the draw, so it too needs
// the matrix to be IN its child — hence expOn in `graded` above. Everything
// downstream that samples "the image" now samples after the exposure.
let exposeShader: any = null;
if (expOn) {
const { exposureEffect: effect } = effects();
if (effect) exposeShader = own(effect.makeShaderWithChildren([evStops], [imageShaderOf()]));
}
const baseShaderOf = () => exposeShader ?? imageShaderOf();
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
// The base is built HERE and not earlier: it draws the same shader the
// tone pass reads, and both have to see the pixels after the exposure.
const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA;
const base = own(blurredBase(baseShaderOf, width, height, sigma));
toneShader = own(
effect.makeShaderWithChildren(toneUniformArray(tone), [
baseShaderOf(),
base ? own(imageShaderChild(base)) : baseShaderOf(),
])
);
}
}
if (cinemaOn) {
const { cinemaEffect: effect } = effects();
if (effect) {
const cs = own(effect.makeShaderWithChildren(cinema.flat, [toneShader ?? baseShaderOf()]));
if (cs) paintShader = cs;
}
}
if (!paintShader && toneShader) paintShader = toneShader;
// The exposure pass must survive as the last member of the chain when nothing
// else is active — it is a shader, not a colour filter, so the draw needs it.
if (!paintShader && exposeShader) paintShader = exposeShader;
// 3e. The curve pass, last: whatever the stock, the matrix, the mixer or the
// seasonal grade did, a point moved on the graph lands the pixel where the
// graph says. The table is an image, so the shader can look it up per channel
// at a computed coordinate — a uniform array cannot be indexed that way.
if (curveOn) {
const { curveEffect: effect } = effects();
const lut = effect
? own(
Skia.Image.MakeImage(
{
width: CURVE_LUT_SIZE,
height: 1,
colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Premul,
},
curveLut(curve),
CURVE_LUT_SIZE * 4
)
)
: null;
const lutShader = lut
? own(
lut.makeShaderOptions(
Skia.TileMode.Clamp,
Skia.TileMode.Clamp,
Skia.FilterMode.Linear,
Skia.MipmapMode.None
)
)
: null;
const cs =
effect && lutShader
? own(effect.makeShaderWithChildren([], [paintShader ?? baseShaderOf(), lutShader]))
: null;
if (cs) paintShader = cs;
}
// Only one of the two may tint the pixels: the graded image already carries
// the matrix, an ungraded draw still needs it.
if (paintShader) paint.setShader(paintShader);
else if (!graded) paint.setColorFilter(colorFilter);
// 5. Draw the graded photo.
if (paintShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
} else {
canvas.drawImage(skImage, 0, 0, paint);
}
// 4. NOISE REDUCTION — thay_doi_thong_so_giong_lightroom.md §4.1, the colour
// half of it (NR_SKSL). It runs HERE and not on the paint, because it needs
// the finished pixels as one child and a blurred copy of them as the other,
// and a `setImageFilter` on the draw can only offer the shader's input. The
// knob WAS that filter — one sigma over all three channels — so the pass that
// took a colour speckle out took the picture's edges with it. Only the chroma
// is blurred now; the luma is the frame's own, so a strand of hair comes back
// where it was whatever the knob says.
const denoiseKnob = adjustments.denoise ?? 0;
if (denoiseKnob > 0) {
const { nrEffect: denoiseEffect } = effects();
const sigma = (denoiseKnob / 10) * NR_CHROMA_SPAN * width;
// The blur is built before the redraw because the redraw overwrites the
// frame the reference is read out of (the same reason DEHAZE reads its dark
// channel and its light first).
const blurred = denoiseEffect ? own(blurredFrame(surface, width, height, sigma)) : null;
if (denoiseEffect && blurred) {
replaceThrough(canvas, surface, width, height, (snap) => {
const shader = denoiseEffect.makeShaderWithChildren(
[Math.min(1, denoiseKnob / 10)],
[own(imageShaderChild(snap)), own(imageShaderChild(blurred))]
);
return shader ? own(shader) : null;
});
}
}
// 4b. DEHAZE and CLARITY — the frame-wide half of the two docs
// (raw_parameter_processing... §3, ki_n_tr_c_multiple_passes... in full). Both
// compare a pixel against a blurred reference of its own surroundings, so the
// reference is built once out of the frame the pass is about to overwrite
// (spatialReference, the doc's separable bilateral pair) and handed in as the
// effect's second child. DEHAZE runs first: it takes the scattered light out,
// and sharpening haze instead would only make it read as detail.
//
// The masks are read here rather than at 6d because a mask's CLARITY/DEHAZE
// asks for the same reference and the same atmospheric light, and the light is
// cheapest to read before the masks have graded the frame.
const masks = readMasks(adjustments.masks);
const maskSpatial = masksHaveSpatial(masks);
let air: [number, number, number] | null = null;
let airRead = false;
const airOf = () => {
if (!airRead) {
airRead = true;
air = atmosphericLight(surface, width, height);
}
return air;
};
// DEHAZE is signed: positive takes scattered light out, negative puts it
// back (the transmission it is handed moves either side of 1, DEHAZE_SKSL's
// own note). Its second child is not the bilateral reference CLARITY reads —
// the prior wants a MINIMUM over a patch, not an average — but the smoothed
// transmission built below, so the reference is still built only for CLARITY
// and the masks that ask for it.
//
// The knob is left to over-correct at the top of its range on purpose: a
// hazy sky goes white and a saturated colour beside a dark edge deepens.
// That is `(c - a)/t + a` with an airlight near 0.93 and a plain clamp, the
// arithmetic the doc asks for, and it is smooth across the frame — 6x zoom
// panels of a hazy frame at DEHAZE 100 show one wide gradient, no band
// repeating at any period. The bands that used to be drawn beside dark
// detail were the patch above, and hiding them behind a knee in the shader
// would soften the correction everywhere to answer a bug that is gone.
// ponytail: no knee; if the over-correction ever needs taming the one number
// that does it is DEHAZE_MAX_OMEGA.
const dehazeAmount = (adjustments.dehaze ?? 0) / 10;
// Through `effects()`, not the module variable: nothing above this pass has
// asked for DEHAZE, so on the first render the raw variable is still null and
// the knob did nothing until some later render happened to fill it in.
const { dehazeEffect: effect } = effects();
if (dehazeAmount !== 0 && effect) {
// The dark channel is read off the frame the pass is about to overwrite,
// like the light and for the same reason: after the pass the pixels are the
// corrected ones and the dark channel they would give is not the haze's.
const a = airOf();
const darkMap = a ? own(dehazeDarkChannel(surface, width, height, a)) : null;
if (a && darkMap) {
replaceThrough(canvas, surface, width, height, (snap) => {
const shader = dehazeEffect.makeShaderWithChildren(
dehazeUniformArray(a, dehazeAmount),
[
own(imageShaderChild(snap)),
own(
darkMap.makeShaderOptions(
Skia.TileMode.Clamp,
Skia.TileMode.Clamp,
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
// A map this small only covers the frame if it is told to. With
// no matrix the image's own texels ARE the shader's coordinates,
// so `dark.eval` reads the map 1:1: the top-left 64 pixels of
// the frame get the real dark channel and every pixel past them
// clamps onto the map's last texel — one constant t over the
// whole photo, i.e. a global inversion instead of a dehaze.
// CanvasKit reads this matrix as the map's own pixels to the
// frame's (measured: `scaled(n/w)` clamps the same way, the
// reciprocal lands on the ideal ramp), hence frame over map,
// as a plain 3x3 in SkMatrix's own order.
[width / DEHAZE_MAP_SIZE, 0, 0, 0, height / DEHAZE_MAP_SIZE, 0, 0, 0, 1]
)
),
]
);
return shader ? own(shader) : null;
});
}
}
// CLARITY is one move in two directions: the frame against its own blurred
// reference above zero, the mix back toward that same reference below it —
// which is exactly what a mask's CLARITY does with the same child, so the
// frame-wide knob and the masked one are the same neighbourhood and the same
// strength (the negative side used to be a mist blur of its own radius).
const clarityKnob = adjustments.clarity ?? 0;
if (clarityKnob !== 0 && clarityBlendEffect) {
replaceThrough(canvas, surface, width, height, (snap) => {
const reference = spatialReference(surface, width, height);
if (!reference) return null;
own(reference);
const shader = clarityBlendEffect.makeShaderWithChildren(
// Amount in the blend's own [-1, 1] units: the positive side's gain
// (CLARITY_DETAIL_GAIN) lives INSIDE the shader now, so the mask's
// CLARITY_GAIN — a different quantity on a different child — is not
// borrowed for this one and the two knobs stay independent.
[clarityKnob / 10],
[own(imageShaderChild(snap)), own(imageShaderChild(reference))]
);
return shader ? own(shader) : null;
});
}
// Negative SHARPENING takes edge enhancement back out — after the conv, so
// it cannot blur away what the CLARITY pass just added.
const sharpKnob = adjustments.sharpening ?? 0;
if (sharpKnob < 0) {
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
}
// 5b. HDF bright-pass bloom. The web demo shows the effect off, so both the
// blur radius and the screen alpha push further than the phone's: a wide
// halo around the highlights instead of a hairline glow.
if ((adjustments.hdf ?? 0) > 0) {
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.006 + 0.024 * strength);
const { glowEffect: effect } = effects();
const srcShader = paintShader ?? baseShaderOf();
const glowShader =
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
const glowPaint = own(Skia.Paint());
glowPaint.setBlendMode(Skia.BlendMode.Screen);
glowPaint.setAlphaf(0.28 + 0.52 * strength);
// The bloom reads the graded pixels now (step 3), so the matrix must not
// land on them a second time; it only wraps the filter when ungraded.
const bloomGrade = (inner?: any) =>
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
const bloomBlur = () => own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null));
if (glowShader != null) {
glowPaint.setShader(glowShader);
const grade = bloomGrade();
const blur = bloomBlur();
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
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,
];
const grade = bloomGrade(
own(Skia.ColorFilter.MakeCompose(own(Skia.ColorFilter.MakeMatrix(hiMatrix)), colorFilter))
);
const blur = bloomBlur();
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
if (paintShader) {
glowPaint.setShader(paintShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(gradeImage, 0, 0, glowPaint);
}
}
}
// 6. Film grain (negative NR re-grains, same ceiling as GRAIN). The field
// itself is the STOCK's — its format's cell, its emulsion's clump and
// spread (grainShader.ts) — and not one global overlay: a 120 negative
// prints finer and smoother than a 35mm one at the same picture width, and
// a colour negative clumps harder than a slide.
const grainAmount = Math.min(
10,
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
);
const stock = grainStockFor(recipe.baseFilter);
if (grainAmount > 0) {
const grainPaint = own(Skia.Paint());
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
grainPaint.setAlphaf(grainAmount / 20);
const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL));
const cell = grainCell(width, stock, adjustments.grainSize);
const noiseShader = noiseEffect ? own(noiseEffect.makeShader(grainUniformArray(cell, stock))) : null;
if (noiseShader) {
grainPaint.setShader(noiseShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
}
}
// 6b. Halation — the stock's own highlight bleed (see HALATION_SKSL): the
// highlights above the threshold, tinted the emulsion's halo colour and
// blurred wide, screened back over the frame. It rides the GRAIN knob like
// the grain does, so GRAIN 0 is still a clean frame and the OFF/WEAK/STRONG
// chips still mean 0/3/6; a sensor stock carries none at any amount.
if (grainAmount > 0 && stock.halation > 0 && stock.halo > 0) {
const { halationEffect: effect } = effects();
const srcShader = paintShader ?? baseShaderOf();
const haloShader = effect ? own(effect.makeShaderWithChildren(halationUniformArray(stock), [srcShader])) : null;
if (haloShader) {
const haloPaint = own(Skia.Paint());
haloPaint.setBlendMode(Skia.BlendMode.Screen);
haloPaint.setAlphaf(stock.halation * (grainAmount / 10) * HALATION_MAX);
haloPaint.setShader(haloShader);
const sigma = halationSigma(width, stock);
haloPaint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), haloPaint);
}
}
// 6c. Vignette.
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],
Skia.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);
}
}
// 6d. The gradient masks — FX's LINEAR / RADIAL GRADIENT (see
// gradientMask.ts). The three knobs each shape carries are applied through
// that shape's own alpha, masks in the order they were drawn, so the second
// reads what the first left. It sits here, after the frame-wide grade and
// before HEAL, because a local adjustment is part of the look and not a
// repair: the pixels a repair borrows are meant to carry it already.
// Same shape as the passes below — the shader reads the frame it is about to
// overwrite — and, when a mask carries CLARITY or DEHAZE, the frame's own
// blurred reference and the atmospheric light come in exactly as they did for
// the frame-wide pair at 4b.
if (masks.length) {
const effect = maskEffectFor(masks.length, maskSpatial);
const a = maskSpatial ? airOf() : null;
replaceThrough(canvas, surface, width, height, (snap) => {
const reference = maskSpatial ? spatialReference(surface, width, height) : null;
if (!effect || (maskSpatial && !reference)) return null;
own(reference);
const children = [own(imageShaderChild(snap))];
if (reference) children.push(own(imageShaderChild(reference)));
const shader = effect.makeShaderWithChildren(maskUniforms(masks, width, height, a), children);
return shader ? own(shader) : null;
});
}
// 6e. HEAL — the FX tab's dust brush (see heal.ts). It is the last thing
// that touches the photo's own pixels, and deliberately so: the borrowed
// patch is copied out of the pixels the pipeline has already built, so a
// repair matches the grade, the blur and the grain around it by
// construction instead of being a second render of the same look that has
// to agree. The card, the wall and the watermarks are drawn after it, so a
// healed spot still carries the frame's own edge.
const heal = readHeal(adjustments.heal);
if (heal.length) {
// The shader for this many spots, cached by count — every spot the recipe
// holds is drawn, so no repair is ever dropped to make room for a later
// one.
const effect = healEffectFor(heal.length);
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(healUniforms(heal, width, height), [child]))
: null;
if (shader) {
// Snapshot, clear, redraw — the pass reads the frame it is about to
// overwrite (see replaceThrough).
canvas.clear(Skia.Color('transparent'));
const healPaint = own(Skia.Paint());
healPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), healPaint);
}
}
// 6f. MOSAIC — the FX tab's hiding brush (see mosaic.ts). It comes after
// HEAL because hiding is the last thing the photo's own pixels are asked
// for: a repair under a hidden patch is work nobody will ever see, and the
// snapshot this pass reads already carries the repairs. It is a hard-edged
// circle of cells, so nothing of what it covers is left legible at its rim.
const mosaic = readMosaic(adjustments.mosaic);
if (mosaic.length) {
const effect = mosaicEffectFor(mosaic.length);
flush(surface);
const snap = own(surface.makeImageSnapshot());
const child = snap
? own(
snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
)
: null;
const shader =
effect && child
? own(effect.makeShaderWithChildren(mosaicUniforms(mosaic, width, height), [child]))
: null;
if (shader) {
canvas.clear(Skia.Color('transparent'));
const mosaicPaint = own(Skia.Paint());
mosaicPaint.setShader(shader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), mosaicPaint);
}
}
release(owned, skImage);
// 7. Frame.
let outputSurface = surface;
let stampCanvas = canvas;
let stampW = width;
let stampH = height;
let cardSource: any = null;
if (frameId === 'polaroid') {
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const outW = width;
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) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const bgPaint = own(Skia.Paint());
bgPaint.setColor(Skia.Color('#faf9f6'));
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
const win = pl.window;
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;
}
// The window is not the size of the photo (0.898x here, more when a frame
// covers), and a plain drawImageRect is nearest on CanvasKit: the print
// would come back staircased for the same reason the straighten draw did.
// Only the *Options/*Cubic call shapes take a sampling option.
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
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 (isWallframe(frameId)) {
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const pw = photoImg.width();
const ph = photoImg.height();
const wallLand = frameId === 'wallframe-landscape' || options?.wallframeLandscape === true;
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W;
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H;
const cardSurface = createSurface(outW, outH);
if (!cardSurface) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const wfImage = loadWallframe(input.assets);
if (!wfImage) {
console.error('Failed to decode wallframe.png');
return null;
}
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
const win = wl.window;
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;
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;
// Filtered for the same reason as the polaroid window above: cover-scaling
// a photo into the opening is a resize, and nearest shows it.
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
Skia.XYWHRect(win.x, win.y, win.w, win.h),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
cardCanvas.save();
if (wallLand) {
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 if (isOldFilm(frameId)) {
// The sheet has the frame's SHAPE, like the walls: `old-film-portrait` is
// the PNG turned 90° CW, so the pair is one standing frame and one lying.
// The sheet's own 914x609 is far too small to hold a photo, so the frame
// is scaled UP to the photo instead of the photo down to the frame: the
// deckle stays a couple of percent of the print at any export size, which
// is also why the photo is cover-cropped into the whole sheet and not
// into a measured window — the sheet's edge is torn, so it has no straight
// sides to cut against; the photo simply runs under all of it.
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const film = loadOldFilm(input.assets);
if (!film) {
console.error('Failed to decode old_film.png');
return null;
}
const portrait = frameId === 'old-film-portrait';
const sheetW = portrait ? film.height() : film.width();
const sheetH = portrait ? film.width() : film.height();
const scale = Math.max(width / sheetW, height / sheetH);
const outW = Math.round(sheetW * scale);
const outH = Math.round(sheetH * scale);
const cardSurface = createSurface(outW, outH);
if (!cardSurface) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const plainPaint = own(Skia.Paint());
const cover = Math.max(outW / width, outH / height);
const coverW = outW / cover;
const coverH = outH / cover;
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect((width - coverW) / 2, (height - coverH) / 2, coverW, coverH),
Skia.XYWHRect(0, 0, outW, outH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
cardCanvas.save();
if (portrait) {
// The wall-frame portrait's own turn: origin to the top-right corner,
// then 90° CW, so the sheet's width runs down the frame and the drawn
// rect is the frame's height by its width.
cardCanvas.translate(outW, 0);
cardCanvas.rotate(90, 0, 0);
}
cardCanvas.drawImageRectOptions(
film,
Skia.XYWHRect(0, 0, film.width(), film.height()),
portrait ? Skia.XYWHRect(0, 0, outH, outW) : Skia.XYWHRect(0, 0, outW, outH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
cardCanvas.restore();
outputSurface = cardSurface;
stampCanvas = cardCanvas;
stampW = outW;
stampH = outH;
} else {
drawFrameOnCanvas(canvas, width, height, frameId);
}
if (cardSource) {
release(owned, cardSource);
release(owned, surface);
}
// 8. GPS + custom watermark.
const customWm = options?.watermark ?? null;
const liteWm = options?.liteMark ?? null;
if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) {
const loaded = loadFaces(input.assets);
if (loaded) {
const { typeface, iconTypeface } = loaded;
const fontSize = Math.round(stampW * 0.032);
const textPaint = own(Skia.Paint());
textPaint.setColor(Skia.Color('#f59e0b'));
const iconPaint = own(Skia.Paint());
iconPaint.setColor(Skia.Color('#ffffff'));
if (input.useGeotag && input.gpsInfo) {
// A phone's GPS carries no place name, and neither does a photo's EXIF,
// so the coordinates are what the stamp prints — the old literal
// 'STREET VIEW' placeholder read as if that were the real location.
const { latitude, longitude } = input.gpsInfo;
const locationName =
input.gpsInfo.locality?.trim() ||
(Number.isFinite(latitude) ? `${latitude.toFixed(5)}, ${longitude.toFixed(5)}` : '');
const d = new Date(input.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(namedFace(gpsPos?.font) ?? typeface, gpsSize));
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
const gpsGap = Math.round(gpsSize * 1.6);
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
const lines: { icon: string; text: string }[] = [];
if (gpsPos?.showName !== false && locationName) lines.push({ icon: '📍', text: locationName });
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
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();
}
if (customWm || liteWm) {
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const wmRot = options?.watermarkRotation ?? 0;
for (const mark of [customWm, liteWm]) {
if (!mark) continue;
const wmSize = Math.round(fontSize * (mark.size ?? 1));
const wmFace = namedFace(mark.font);
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
const wmPaint = own(Skia.Paint());
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
const tx = clamp01(mark.x) * stampW;
const ty = clamp01(mark.y) * stampH + 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, optional output sharpen, JPEG encode + DPI patch.
flush(outputSurface);
let resultImage = own(outputSurface.makeImageSnapshot());
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
if (sharpenAmount > 0) {
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
if (sharpened && sharpened !== resultImage) {
own(sharpened);
release(owned, resultImage);
resultImage = sharpened;
}
}
// 9b. The strip the file carries (export only, never the preview). Drawn
// onto a taller surface so every frame variant is treated the same: the
// tagline sits on the photo's top-left, the caption band hangs below.
const caption = options?.caption ?? null;
if (caption) {
const W = resultImage.width();
const band = Math.round(W * 0.155);
const capSurface = createSurface(W, resultImage.height() + band);
const loaded = loadFaces(input.assets);
if (capSurface && loaded) {
own(capSurface);
const cc = capSurface.getCanvas();
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
const pad = Math.round(W * 0.03);
// Amber over a black drop, so the tagline still reads on a bright sky.
const tagSize = Math.round(W * 0.026);
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
const shadow = own(Skia.Paint());
shadow.setColor(Skia.Color('#000000'));
const amber = own(Skia.Paint());
amber.setColor(Skia.Color('#f59e0b'));
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
const bandTop = resultImage.height();
const bandPaint = own(Skia.Paint());
bandPaint.setColor(Skia.Color('#0b0b0b'));
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
const titleSize = Math.round(W * 0.042);
const metaSize = Math.round(W * 0.026);
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
const titlePaint = own(Skia.Paint());
titlePaint.setColor(Skia.Color('#f2f2f2'));
const metaPaint = own(Skia.Paint());
metaPaint.setColor(Skia.Color('#9a9a9a'));
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7), metaPaint, metaFont);
flush(capSurface);
const composed = own(capSurface.makeImageSnapshot());
release(owned, resultImage);
resultImage = composed;
} else if (capSurface) {
capSurface.dispose();
}
}
// 10. The live view stops here: it wants the picture, not a file, and it
// says so with `drawTo` — no encode, no DPI patch, no bytes.
if (input.drawTo) {
input.drawTo(resultImage);
return { bytes: new Uint8Array(), width: resultImage.width(), height: resultImage.height() };
}
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
if (!bytes || bytes.length === 0) {
console.error('Failed to encode image to JPEG');
return null;
}
if (options?.dpi && options.dpi > 0) {
bytes = patchJpegDpi(bytes, options.dpi);
}
return { bytes, width: resultImage.width(), height: resultImage.height() };
} catch (error) {
console.error('Render failed:', error);
return null;
} finally {
disposeAll(owned);
}
}