Files
RecipesCam/web/src/engine/exportEngine.ts
T
3dtours 438788e031 web (engine): print the grain from the phone's own module
The web port carried a third copy of the grain shader, so it would have kept
printing the old static, unseeded field after the phone stopped. It now imports
GRAIN_SKSL and grainCell from src/utils/grainShader.ts, exactly like the phone's
export, so there is one shader and one size rule for the whole pipeline. The
floor is one output pixel here, which is the whole story on this path: a
CanvasKit surface is rasterised in the pixels it is given, so no dp-to-device
correction is needed the way the phone preview needs one.

Typechecked only — no harness in the tree runs this file; the deployed app
(docker/frontend) has its own engine and already prints the new grain.
2026-09-23 10:10:53 +07:00

681 lines
26 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 '../../../src/types';
import { ASPECT_RATIO_W_H } from '../../../src/types';
import { getSkiaColorMatrix, applyExposureGain } from '../../../src/utils/colorUtils';
import {
TONE_SKSL,
GLOW_SKSL,
CLARITY_SKSL,
getToneUniforms,
toneIsActive,
toneUniformArray,
glowUniformArray,
} from '../../../src/utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../../src/utils/cinemaShader';
import { GRAIN_SKSL, GRAIN_SEED, grainCell } from '../../../src/utils/grainShader';
import {
drawFrameOnCanvas,
polaroidLayout,
POLAROID_CARD,
POLAROID_WIN_W,
wallframeLayout,
WALLFRAME_W,
WALLFRAME_H,
WALLFRAME_LAND_W,
WALLFRAME_LAND_H,
} from '../../../src/utils/frameUtils';
import { patchJpegDpi } from '../../../src/utils/jpegDpi';
import { applyPhotoRotation } from '../../../src/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;
}
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);
}
// 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;
}
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) && frameId !== 'wallframe') {
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).
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));
paint.setColorFilter(colorFilter);
// 3b. Tone shader.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
let toneShader: any = null;
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
const imageShader = own(
skImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
)
);
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
if (toneShader) paint.setShader(toneShader);
}
}
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);
let paintShader = toneShader;
if (cinema && cinemaIsActive(cinema.flat)) {
const { cinemaEffect: effect } = effects();
if (effect) {
const child =
toneShader ??
own(skImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
));
const cs = own(effect.makeShaderWithChildren(cinema.flat, [child]));
if (cs) {
paintShader = cs;
paint.setShader(paintShader);
}
}
}
// 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.
if ((adjustments.hdf ?? 0) > 0) {
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.004 + 0.015 * strength);
const { glowEffect: effect } = effects();
const srcShader =
paintShader ??
own(skImage.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
));
const glowShader =
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
const glowPaint = own(Skia.Paint());
glowPaint.setBlendMode(Skia.BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
if (glowShader != null) {
glowPaint.setShader(glowShader);
glowPaint.setImageFilter(
own(Skia.ImageFilter.MakeCompose(
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
))
);
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,
];
glowPaint.setImageFilter(
own(Skia.ImageFilter.MakeCompose(
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
own(Skia.ImageFilter.MakeColorFilter(
own(Skia.ColorFilter.MakeCompose(
own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
colorFilter
)),
null
))
))
);
if (paintShader) {
glowPaint.setShader(paintShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(skImage, 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);
// Same shader and same cell rule as the phone's export and the preview
// (see src/utils/grainShader.ts): one noise cell per 1/1080 of the
// picture's width, floored at one output pixel.
const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL));
const noiseShader = noiseEffect
? own(noiseEffect.makeShader([grainCell(width), GRAIN_SEED[0], GRAIN_SEED[1]]))
: 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 (frameId === 'wallframe') {
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const pw = photoImg.width();
const ph = photoImg.height();
const wallLand = 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;
}
}
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);
}
}