// 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, CK } 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, TONE_BASE_SKSL, EXPOSURE_SKSL, GLOW_SKSL, SHARPEN_SKSL, CLARITY_BLUR_SKSL, CLARITY_BLEND_SKSL, NR_SKSL, NR_CHROMA_SPAN, DEHAZE_SKSL, dehazeUniformArray, getToneUniforms, toneBaseSurface, TONE_BASE_SCALE, 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 { HEAL_CUT_SKSL, HEAL_FILL_SKSL, HEAL_FEATHER, HEAL_FILL_BLUR, HEAL_FILL_MAX_LEVELS, HEAL_MASK_STOPS, HEAL_PATCH_STOPS, readHeal, } from '../../shared/utils/heal'; import { inpaintCrop, inpaintCropSize, INPAINT_REF_LONG, onClearInpaint } from './inpaint'; 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; customFrame?: { bytes: Uint8Array; hole: { x: number; y: number; w: number; h: number }; scale?: number; panX?: number; panY?: number; } | null; // 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 toneBaseEffect: any = null; let clarityBlendEffect: any = null; let nrEffect: any = null; let dehazeEffect: any = null; let healCutEffect: any = null; let healFillEffect: 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 (!toneBaseEffect) toneBaseEffect = Skia.RuntimeEffect.Make(TONE_BASE_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); if (!healCutEffect) healCutEffect = Skia.RuntimeEffect.Make(HEAL_CUT_SKSL); if (!healFillEffect) healFillEffect = Skia.RuntimeEffect.Make(HEAL_FILL_SKSL); return { sharpenEffect, toneEffect, exposureEffect, cinemaEffect, glowEffect, halationEffect, curveEffect, clarityBlurEffect, toneBaseEffect, clarityBlendEffect, nrEffect, dehazeEffect, healCutEffect, healFillEffect, }; } // FX's HEAL no longer depends on the recipe: what the brush paints is a mask, // and the fill behind it is one solve over that mask rather than one block per // spot, so the two passes are the same for a speck and for a long stroke and are // built once like the rest. The list is uncapped for the same reason — a hundred // more spots are a hundred more circles in the mask and not a longer shader. // 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(); 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(); 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's own neighbourhood level out to // TONE_BASE_RADIUS, as an image the tone shader takes as its second child and // reads once per pixel. NOT a blur — a blur is an average of whatever is in the // neighbourhood, so at the frame's strong edges it lands across them and the // pixels beside a bright sky take the shadow side's move (the halo of // docker/halo_highlight_shadow_white_black.md). TONE_BASE_SKSL is the edge-aware // pass instead: one bilateral 1xN per axis, over a 1 px line-blind med3 of the // same axis, so the base is the level of the pixel's OWN side of an edge and a // twig finer than a couple of pixels is detail rather than tone (see // TONE_BASE_RADIUS in toneShader.ts), on a surface TONE_BASE_SCALE of the frame // so the two passes cost a quarter of what they do at full size and the base // carries exactly the detail a level that wide can. Two axis passes rather than // one NxN kernel is the same 2n+1 reads CLARITY_BLUR_SKSL buys its reference // with. // // Null only if the effect or a surface will not come up, in which case the caller // hands the tone shader the sharp child as its base: the base then lands on the // pixel and the ramp falls back to the global move. `shaderOf` is left owned by // the caller — the tone pass reads the same shader as its first child. function toneBaseAxis( effect: any, child: any, w: number, h: number, dx: number, dy: number ): any | null { const surf = createSurface(w, h); if (!surf) return null; try { const shader = effect.makeShaderWithChildren([dx, dy], [child]); 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 { surf.dispose(); } } // The base the ramp reads, at TONE_BASE_SCALE of the frame: two axis passes over // toneBaseSurface's own step — the horizontal one reading the frame's full-size // child through baseDownscale, the vertical one reading what it left at 1:1. A // 1:1 draw is what keeps `dir` in the pass's OWN pixels (TONE_BASE_SKSL): the // scale lives in the child's local matrix, not in the canvas, so the tap offsets // are the half's own and the second pass needs no coordinate of its own. function toneBase(shaderOf: (lm?: any) => any, w: number, h: number): any | null { const { toneBaseEffect: effect } = effects(); if (!effect) return null; const { width: bw, height: bh, step } = toneBaseSurface(w, h); const frame = shaderOf(baseDownscale()); if (!frame) return null; const horizontal = toneBaseAxis(effect, frame, bw, bh, step, 0); if (!horizontal) return null; const child = imageShaderChild(horizontal); try { if (!child) return null; return toneBaseAxis(effect, child, bw, bh, 0, step); } finally { disposeAll([child]); horizontal.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, localMatrix?: any, fm = Skia.FilterMode.Linear): any { const tx = Skia.TileMode.Clamp; return localMatrix ? image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None, localMatrix) : image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None); } // The base is built on a surface TONE_BASE_SCALE of the frame, so the frame's own // child is handed to it with the matrix that puts the frame's coordinates on that // surface (the local matrix runs the child's reads through its own space, i.e. // twice where the pixel is). Skia's sampler then does the box downscale for free, // to the same bytes as the equivalent 2:1 drawImageRectOptions — measured in the // head of tone-local-contrast-check.mjs. `CK` rather than the shim's `Skia`: the // RN API this file is a port of has no local matrix, so the shim has none. function baseDownscale(): any { return CK.Matrix.scaled(TONE_BASE_SCALE, TONE_BASE_SCALE); } // ...and the base comes back the same way: the tone pass reads it at the frame's // own size through the inverse matrix, and the sampler does the 2x upscale — // NEAREST, not linear. The half the pass leaves is one texel per two frame // pixels, so a linear 2x read weights the texel across a step edge by a quarter // on the pixel beside it, and that quarter of the far side's level is exactly the // halo the edge-aware base exists to avoid (2 px at -20.9/255 on the step-edge // frame, measured in tone-local-contrast-check.mjs). Nearest replicates the texel // instead and the step lands on the edge; the base is a smoothed signal, so the // 2x2 blocks it costs are the levels the bilateral already had, and every pixel // still reads toneCurve at its own base. The same sampler calibrated: nearest at // 2x is floor(X / 2) exactly, 0/256 mismatches on a 16x16 field. function baseUpscale(): any { return CK.Matrix.scaled(1 / TONE_BASE_SCALE, 1 / TONE_BASE_SCALE); } // The same child, read in the frame's coordinates rather than its own: a pass // that builds its image on a surface smaller than the frame (HEAL's fill, which // is only ever asked for the pixels around the brush) has to say where that // surface's corner sits. The shift is a whole number of pixels and the draw // that filled it was 1:1, so the sampling stays exact — the reason HEAL uses a // box at all rather than a scaled draw (see the fill in step 6e). function imageShaderChildAt(image: any, x: number, y: number): any { return image.makeShaderOptions( Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None, [1, 0, x, 0, 1, y, 0, 0, 1] ); } // 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; 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, custom frames). const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id) || id === 'custom'; function loadOldFilm(assets: RenderAssets) { if (oldFilmImage) return oldFilmImage; oldFilmImage = Skia.Image.MakeImageFromEncoded(assets.oldFilm); return oldFilmImage; } function clusterHealSpots( spots: { x: number; y: number; r: number; stroke?: number }[], width: number, height: number ): { x: number; y: number; r: number; stroke?: number }[][] { if (spots.length <= 1) return spots.length ? [spots] : []; const n = spots.length; const adj: number[][] = Array.from({ length: n }, () => []); for (let i = 0; i < n; i++) { const si = spots[i]; const xi = si.x * width; const yi = si.y * height; const ri = si.r * width; for (let j = i + 1; j < n; j++) { const sj = spots[j]; if (si.stroke !== sj.stroke) continue; const xj = sj.x * width; const yj = sj.y * height; const rj = sj.r * width; const maxDist = (ri + rj) * 1.5; const distSq = (xi - xj) ** 2 + (yi - yj) ** 2; if (distSq <= maxDist * maxDist) { adj[i].push(j); adj[j].push(i); } } } const isMob = typeof navigator !== 'undefined' && /iPad|iPhone|iPod|Android|Mobile/i.test(navigator.userAgent || ''); const maxSpan = isMob ? 600 : 1024; const visited = new Uint8Array(n); const clusters: { x: number; y: number; r: number; stroke?: number }[][] = []; for (let i = 0; i < n; i++) { if (visited[i]) continue; visited[i] = 1; const cluster: { x: number; y: number; r: number; stroke?: number }[] = [spots[i]]; let minX = spots[i].x * width; let maxX = minX; let minY = spots[i].y * height; let maxY = minY; const queue = [i]; while (queue.length > 0) { const curr = queue.shift()!; for (const neighbor of adj[curr]) { if (!visited[neighbor]) { const nx = spots[neighbor].x * width; const ny = spots[neighbor].y * height; const newMinX = Math.min(minX, nx); const newMaxX = Math.max(maxX, nx); const newMinY = Math.min(minY, ny); const newMaxY = Math.max(maxY, ny); if (newMaxX - newMinX <= maxSpan && newMaxY - newMinY <= maxSpan) { visited[neighbor] = 1; cluster.push(spots[neighbor]); minX = newMinX; maxX = newMaxX; minY = newMinY; maxY = newMaxY; queue.push(neighbor); } } } } clusters.push(cluster); } return clusters; } interface HealedBaseCache { baseImage: any; healKey: string; width: number; height: number; healedSnapshot: any; } let cachedHealedBase: HealedBaseCache | null = null; export function clearHealBaseCache(): void { if (cachedHealedBase?.healedSnapshot) { try { cachedHealedBase.healedSnapshot.dispose?.(); } catch {} } cachedHealedBase = null; } onClearInpaint(clearHealBaseCache); async function applyHealToBase( baseImage: any, healSpots: { x: number; y: number; r: number; stroke?: number }[], width: number, height: number ): Promise { const healKey = healSpots .map((s) => `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.r.toFixed(4)},${s.stroke ?? 0}`) .join(';'); if ( cachedHealedBase && cachedHealedBase.baseImage === baseImage && cachedHealedBase.healKey === healKey && cachedHealedBase.width === width && cachedHealedBase.height === height ) { return cachedHealedBase.healedSnapshot; } let curImage = baseImage; const tempOwned: any[] = []; const tempOwn = (obj: T | null | undefined): T => { if (obj) tempOwned.push(obj); return obj as T; }; try { const { healCutEffect: cutEffect, healFillEffect: fillEffect } = effects(); const clusters = clusterHealSpots(healSpots, width, height); for (const heal of clusters) { const snap = curImage; const maskSurface = tempOwn(createSurface(width, height)); if (!maskSurface) continue; const maskCanvas = maskSurface.getCanvas(); maskCanvas.clear(Skia.Color('transparent')); for (const s of heal) { const cx = s.x * width; const cy = s.y * height; const rad = s.r * width; const shader = tempOwn( Skia.Shader.MakeRadialGradient( [cx, cy], rad, new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]), HEAL_MASK_STOPS, Skia.TileMode.Clamp ) ); const paint = tempOwn(Skia.Paint()); paint.setAntiAlias(true); paint.setShader(shader); maskCanvas.drawCircle(cx, cy, rad, paint); } flush(maskSurface); const maskImage = tempOwn(maskSurface.makeImageSnapshot()); const cutSurface = tempOwn(createSurface(width, height)); if (snap && maskImage && cutEffect && cutSurface) { const cutShader = tempOwn( cutEffect.makeShaderWithChildren([], [tempOwn(imageShaderChild(snap)), tempOwn(imageShaderChild(maskImage))]) ); if (cutShader) { const cutCanvas = cutSurface.getCanvas(); cutCanvas.clear(Skia.Color('transparent')); const cutPaint = tempOwn(Skia.Paint()); cutPaint.setShader(cutShader); cutCanvas.drawRect(Skia.XYWHRect(0, 0, width, height), cutPaint); } } flush(cutSurface); const cutImage = tempOwn(cutSurface.makeImageSnapshot()); const widest = Math.max(...heal.map((s) => s.r * width)); const xs = heal.map((s) => s.x * width); const ys = heal.map((s) => s.y * height); const span = Math.hypot(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)); const levelCount = Math.min( HEAL_FILL_MAX_LEVELS, Math.max(2, 1 + Math.ceil(Math.log2(2 + span / 2 / widest))) ); const pad = Math.max(64, Math.min(512, Math.round(widest * 3 + span * 0.6))); const boxLeft = Math.max(0, Math.floor(Math.min(...xs) - widest - pad)); const boxTop = Math.max(0, Math.floor(Math.min(...ys) - widest - pad)); const boxRight = Math.min(width, Math.ceil(Math.max(...xs) + widest + pad)); const boxBottom = Math.min(height, Math.ceil(Math.max(...ys) + widest + pad)); const fillSurface = tempOwn(createSurface(boxRight - boxLeft, boxBottom - boxTop)); if (!fillSurface) continue; const fillCanvas = fillSurface.getCanvas(); const box = Skia.XYWHRect(boxLeft, boxTop, boxRight - boxLeft, boxBottom - boxTop); const onto = Skia.XYWHRect(0, 0, boxRight - boxLeft, boxBottom - boxTop); fillCanvas.clear(Skia.Color('transparent')); if (cutImage) { for (let k = levelCount; k >= 1; k--) { const sigma = HEAL_FILL_BLUR * widest * 2 ** (k - 1); const blurPaint = tempOwn(Skia.Paint()); blurPaint.setImageFilter(tempOwn(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null))); fillCanvas.drawImageRect(cutImage, box, onto, blurPaint); } fillCanvas.drawImageRect(cutImage, box, onto, tempOwn(Skia.Paint())); } const subClusters = clusterHealSpots(heal, width, height); for (const cluster of subClusters) { const cWidest = Math.max(...cluster.map((s) => s.r * width)); const cXs = cluster.map((s) => s.x * width); const cYs = cluster.map((s) => s.y * height); const mx0 = Math.min(...cXs) - cWidest; const my0 = Math.min(...cYs) - cWidest; const mx1 = Math.max(...cXs) + cWidest; const my1 = Math.max(...cYs) + cWidest; const cSpanW = mx1 - mx0; const cSpanH = my1 - my0; const cSpan = Math.max(cSpanW, cSpanH); const margin = Math.max(48, Math.min(192, Math.round(Math.max(cWidest * 2.5, cSpan * 0.4) + 24))); const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin)); const srcTop = Math.max(boxTop, Math.floor(my0 - margin)); const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin)); const srcBottom = Math.min(boxBottom, Math.ceil(my1 + margin)); const srcW = srcRight - srcLeft; const srcH = srcBottom - srcTop; if (srcW >= 8 && srcH >= 8) { const { w: mw, h: mh } = inpaintCropSize(srcW, srcH); const cropSurface = tempOwn(createSurface(mw, mh)); if (cropSurface) { cropSurface.getCanvas().drawImageRect( snap, Skia.XYWHRect(srcLeft, srcTop, srcW, srcH), Skia.XYWHRect(0, 0, mw, mh), tempOwn(Skia.Paint()) ); flush(cropSurface); const cropPixels = cropSurface.getCanvas().readPixels(0, 0, { width: mw, height: mh, colorType: Skia.ColorType.RGBA_8888, alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB, }); if (cropPixels) { const kx = mw / srcW; const ky = mh / srcH; const hole = new Uint8Array(mw * mh); for (const s of cluster) { const cx = (s.x * width - srcLeft) * kx; const cy = (s.y * height - srcTop) * ky; const rad = s.r * width * kx; const x0 = Math.max(0, Math.floor(cx - rad)); const x1 = Math.min(mw - 1, Math.ceil(cx + rad)); const y0 = Math.max(0, Math.floor(cy - rad)); const y1 = Math.min(mh - 1, Math.ceil(cy + rad)); for (let y = y0; y <= y1; y++) { const dy = y - cy; const row = y * mw; for (let x = x0; x <= x1; x++) { const dx = x - cx; if (dx * dx + dy * dy <= rad * rad) hole[row + x] = 1; } } } const patchKey = `${mw}x${mh}_` + cluster.map((s) => `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.r.toFixed(4)}`).join(';'); (hole as { patchKey?: string }).patchKey = patchKey; const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null); if (patch) { const patchSurface = tempOwn(createSurface(mw, mh)); if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) { flush(patchSurface); const patchImage = tempOwn(patchSurface.makeImageSnapshot()); if (patchImage) { const patchMaskSurface = tempOwn(createSurface(srcW, srcH)); if (patchMaskSurface) { const patchCanvas = patchMaskSurface.getCanvas(); patchCanvas.clear(Skia.Color('transparent')); for (const s of cluster) { const cx = s.x * width - srcLeft; const cy = s.y * height - srcTop; const rad = s.r * width; const shader = tempOwn( Skia.Shader.MakeRadialGradient( [cx, cy], rad, new Float32Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0]), HEAL_PATCH_STOPS, Skia.TileMode.Clamp ) ); const spotPaint = tempOwn(Skia.Paint()); spotPaint.setAntiAlias(true); spotPaint.setShader(shader); patchCanvas.drawCircle(cx, cy, rad, spotPaint); } const patchPaint = tempOwn(Skia.Paint()); patchPaint.setAntiAlias(true); patchPaint.setBlendMode(Skia.BlendMode.SrcIn); patchCanvas.drawImageRect( patchImage, Skia.XYWHRect(0, 0, mw, mh), Skia.XYWHRect(0, 0, srcW, srcH), patchPaint ); flush(patchMaskSurface); const maskedPatch = tempOwn(patchMaskSurface.makeImageSnapshot()); if (maskedPatch) { fillCanvas.drawImageRect( maskedPatch, Skia.XYWHRect(0, 0, srcW, srcH), Skia.XYWHRect(srcLeft - boxLeft, srcTop - boxTop, srcW, srcH), tempOwn(Skia.Paint()) ); } } } } } } } } } flush(fillSurface); const fillImage = tempOwn(fillSurface.makeImageSnapshot()); if (fillImage && snap && maskImage && fillEffect) { const shader = tempOwn( fillEffect.makeShaderWithChildren([], [ tempOwn(imageShaderChild(snap)), tempOwn(imageShaderChildAt(fillImage, boxLeft, boxTop)), tempOwn(imageShaderChild(maskImage)), ]) ); if (shader) { const clusterSurface = tempOwn(createSurface(width, height)); if (clusterSurface) { const clusterCanvas = clusterSurface.getCanvas(); clusterCanvas.clear(Skia.Color('transparent')); const healPaint = tempOwn(Skia.Paint()); healPaint.setShader(shader); clusterCanvas.drawRect(Skia.XYWHRect(0, 0, width, height), healPaint); flush(clusterSurface); const clusterSnap = clusterSurface.makeImageSnapshot(); if (clusterSnap) { curImage = clusterSnap; tempOwn(clusterSnap); } } } } } if (cachedHealedBase?.healedSnapshot && cachedHealedBase.healedSnapshot !== curImage) { try { cachedHealedBase.healedSnapshot.dispose?.(); } catch {} } cachedHealedBase = { baseImage, healKey, width, height, healedSnapshot: curImage, }; return curImage; } finally { for (const obj of tempOwned) { if (obj !== curImage) { try { obj.dispose?.(); } catch {} } } } } export async function renderPhoto(input: RenderInput): Promise { const { recipe, frameId, options } = input; const owned: SkDisposable[] = []; const own = (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; // 2b. Base photo HEAL: applied directly to skImage before color grading and exposure. // In professional RAW engines (Lightroom, Camera Raw), spot healing operates on // base pixels so that exposure and tone adjustments grade the healed area and // surrounding pixels uniformly in exact unison, preventing patches from standing // out when brightness is adjusted. const healSpots = readHeal(adjustments.heal); if (healSpots.length > 0) { const healed = await applyHealToBase(skImage, healSpots, width, height); if (healed && healed !== skImage) { own(healed); release(owned, skImage); skImage = healed; } } // 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 an // edge-aware copy of the tone shader's own child (toneBase), not a ring of // samples inside it and not a blur either: see TONE_BASE_RADIUS for why a // ring was the mottle bug and a blur is the halo one. It comes back at // TONE_BASE_SCALE and is upscaled by its own shader's local matrix (see // baseUpscale). 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 = (lm?: any) => own(imageShaderChild(gradeImage, lm)); // 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; let exposeEffect: any = null; if (expOn) { const { exposureEffect: effect } = effects(); exposeEffect = effect; if (effect) exposeShader = own(effect.makeShaderWithChildren([evStops], [imageShaderOf()])); } // The frame's own child, at the coordinate space the caller asks for: the // base pass reads it at TONE_BASE_SCALE of the frame (see toneBase), every // other caller reads the frame itself. The exposure, if there is one, has to // be rebuilt over the scaled child — it is a per-pixel map, so its output is // the same either way, but the pixels it reads are not. const baseShaderOf = (lm?: any): any => { if (!lm) return exposeShader ?? imageShaderOf(); if (!exposeShader || !exposeEffect) return imageShaderOf(lm); return own(exposeEffect.makeShaderWithChildren([evStops], [imageShaderOf(lm)])); }; 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. // It comes back TONE_BASE_SCALE of the frame, which is why the tone pass // takes it with baseUpscale()'s local matrix rather than as it stands — // and reads it NEAREST, so the upscale replicates the half's texels // instead of weighting the far side of an edge into the pixel beside it. const base = own(toneBase(baseShaderOf, width, height)); toneShader = own( effect.makeShaderWithChildren(toneUniformArray(tone), [ baseShaderOf(), base ? own(imageShaderChild(base, baseUpscale(), Skia.FilterMode.Nearest)) : 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. Down darkens the corners and up lifts them, which is the // lens' own falloff taken out: the same curve and the same weight, with a // white ink where the darkening one would be. Up was refused while the knob // could not go below zero; a corner that is dark in the negative and bright // in the positive is the one knob for both. const vignette = adjustments.vignette ?? 0; if (vignette !== 0) { const v = Math.min(10, Math.abs(vignette)) / 10; const ink = vignette > 0 ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)'; 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(ink)], [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 works from 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; }); } // 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 if (frameId === 'custom' && options?.customFrame) { flush(surface); const photoImg = own(surface.makeImageSnapshot()); cardSource = photoImg; const pw = photoImg.width(); const ph = photoImg.height(); const customImg = own(Skia.Image.MakeImageFromEncoded(options.customFrame.bytes)); if (!customImg) { console.error('Failed to decode custom frame'); return null; } const fw = customImg.width(); const fh = customImg.height(); const cardSurface = createSurface(fw, fh); if (!cardSurface) return null; own(cardSurface); const cardCanvas = cardSurface.getCanvas(); const plainPaint = own(Skia.Paint()); const hole = options.customFrame.hole; const holeX = hole.x * fw; const holeY = hole.y * fh; const holeW = hole.w * fw; const holeH = hole.h * fh; const holeCenterX = holeX + holeW / 2; const holeCenterY = holeY + holeH / 2; // Base cover scale to fill the transparent hole const baseScale = Math.max(holeW / pw, holeH / ph); const zoom = options.customFrame.scale ?? 1.0; const finalScale = baseScale * zoom; const drawW = pw * finalScale; const drawH = ph * finalScale; const panX = options.customFrame.panX ?? 0; const panY = options.customFrame.panY ?? 0; const drawX = holeCenterX + panX - drawW / 2; const drawY = holeCenterY + panY - drawH / 2; // 1. Draw photo underneath cardCanvas.drawImageRectOptions( photoImg, Skia.XYWHRect(0, 0, pw, ph), Skia.XYWHRect(drawX, drawY, drawW, drawH), Skia.FilterMode.Linear, Skia.MipmapMode.None, plainPaint ); // 2. Draw custom frame on top so transparent area displays the photo cardCanvas.drawImageRectOptions( customImg, Skia.XYWHRect(0, 0, fw, fh), Skia.XYWHRect(0, 0, fw, fh), Skia.FilterMode.Linear, Skia.MipmapMode.None, plainPaint ); outputSurface = cardSurface; stampCanvas = cardCanvas; stampW = fw; stampH = fh; } 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); } }