// 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(); 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(); 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 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; 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 { 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; // 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); } }