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