Files
RecipesCam/docker/frontend/src/engine/exportEngine.ts
T
3dtours b4d5d2926b web: give each member a photo folder and burn the strip into the export
Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
2026-09-18 10:56:26 +07:00

771 lines
30 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 two convolution
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
// as a shader pass. `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.
// - 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, applyExposureGain } from '../../shared/utils/colorUtils';
import {
TONE_SKSL,
GLOW_SKSL,
CLARITY_SKSL,
getToneUniforms,
toneIsActive,
toneUniformArray,
glowUniformArray,
} from '../../shared/utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader';
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
}
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 } | null;
wallframeLandscape?: boolean;
frameWindowZoom?: { s: number; u: number; v: number } | null;
photoRotation?: 0 | 90 | 180 | 270;
photoStraighten?: number;
// 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;
}
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 cinemaEffect: any = null;
let glowEffect: any = null;
function effects() {
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect };
}
// CLARITY_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 };
}
// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
// outermost filter of the draw), so the pass reads a snapshot of the surface
// and writes the sharpened result back over the same rect.
function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
flush(surface);
const snap = surface.makeImageSnapshot();
if (!snap) return;
drawConvolved(canvas, snap, w, h, amount);
snap.dispose();
}
// 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]);
}
// --- fonts / artwork --------------------------------------------------------
let faces: { typeface: any; iconTypeface: any } | null = null;
let wallframeImage: 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 custom watermark's FONT option: register the
// bundled face so at least that one name resolves on web.
try {
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
} catch {
/* provider optional */
}
return faces;
}
// 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;
}
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 = Skia.Image.MakeImageFromEncoded(input.source);
if (!skImage0) {
console.error('Failed to parse source image');
return null;
}
own(skImage0);
let skImage = skImage0;
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
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;
if ((cropRect || ratioWH) && !isWallframe(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 (+2^EV gain 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 image, so the highlight
// mask finally sees the exposed value it is meant to pull back.
const evStops = adjustments.exposureCompensation ?? 0;
const evMatrix = options?.evFromCamera
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
// 3b. Tone shader.
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);
let paintShader: any = null;
let gradeImage = skImage;
const graded = (toneIsActive(tone) || cinemaOn) && !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
)
);
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
}
}
if (cinemaOn) {
const { cinemaEffect: effect } = effects();
if (effect) {
const cs = own(effect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
if (cs) paintShader = cs;
}
}
if (!paintShader && toneShader) paintShader = toneShader;
// 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);
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
if (adjustments.denoise > 0) {
const sigma = (adjustments.denoise / 10) * 0.6;
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
}
// 5. Draw the graded photo.
if (paintShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
} else {
canvas.drawImage(skImage, 0, 0, paint);
}
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
if (adjustments.clarity > 0) {
convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
} else if (adjustments.clarity < 0) {
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
drawBlurred(canvas, surface, width, height, mistSigma);
}
// 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 ?? imageShaderOf();
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. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
const grainAmount = Math.min(
10,
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
);
if (grainAmount > 0) {
const grainPaint = own(Skia.Paint());
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
grainPaint.setAlphaf(grainAmount / 20);
const noiseEffect = own(Skia.RuntimeEffect.Make(`
uniform float u;
float grainHash(vec2 q) {
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
}
vec4 main(vec2 pos) {
vec2 p = pos.xy / max(u, 1.0);
vec2 i = floor(p);
vec2 f = p - i;
f = f * f * (3.0 - 2.0 * f);
float g00 = grainHash(i);
float g10 = grainHash(i + vec2(1.0, 0.0));
float g01 = grainHash(i + vec2(0.0, 1.0));
float g11 = grainHash(i + vec2(1.0, 1.0));
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
}
`));
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
if (noiseShader) {
grainPaint.setShader(noiseShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
}
}
// 6b. 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);
}
}
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;
}
cardCanvas.drawImageRect(
photoImg,
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
plainPaint
);
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;
cardCanvas.drawImageRect(
photoImg,
Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
Skia.XYWHRect(win.x, win.y, win.w, win.h),
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 {
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) {
const locationName = input.gpsInfo.locality || 'STREET VIEW';
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(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) 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 = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
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();
}
}
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);
}
}