Add CanvasKit web engine spike + W0 findings (recipes-web)

This commit is contained in:
2026-09-17 16:58:29 +07:00
parent cef3ddb307
commit 8b4c4aa591
16 changed files with 2207 additions and 81 deletions
+690
View File
@@ -0,0 +1,690 @@
// 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 {
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);
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 (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);
}
}
+198
View File
@@ -0,0 +1,198 @@
// CanvasKit-backed stand-in for `@shopify/react-native-skia`.
//
// The web build aliases the package name to THIS module (vite `resolve.alias`,
// tsconfig `paths`), so every renderer we share with the app — `frameUtils.ts`,
// `skiaImage.ts`, the ported `exportEngine.ts` — runs unchanged on CanvasKit.
//
// W0 probe results that shaped this file (measured in Node, canvaskit-wasm 0.42):
// - the default build CANNOT encode JPEG (`encodeToBytes(JPEG)` → null), the
// `canvaskit-wasm/full` build can → always import the full build (+0.9MB wasm);
// - `Skia.ImageFilter.MakeMatrixConvolution` DOES NOT EXIST in CanvasKit → the
// two convolution steps (CLARITY, output sharpen) are re-implemented as an
// SkSL runtime effect, see convolve.ts;
// - `Surface.MakeOffscreen` does not exist either → raster `MakeSurface` only
// (workers have no GPU context, which is fine: the render is CPU anyway);
// - `Skia.Font`/`Typeface.MakeFreeTypeFaceFromData`/`Canvas.drawText` all work,
// and `assets/NotoEmoji-GPS.ttf` really paints 📍/📷 (1731 lit pixels in the
// probe) → GPS watermark needs no Canvas2D fallback.
import CanvasKitInit from 'canvaskit-wasm/full';
// Live binding: modules import `{ Skia }` once, `initSkia()` fills it in before
// the first render. ESM live bindings make the late assignment visible.
export let Skia: any = null;
export let CK: any = null;
export type SkCanvas = any;
export type SkPaint = any;
export type SkImage = any;
export type SkShader = any;
export type SkSurface = any;
export type SkTypeface = any;
export type SkFont = any;
// CanvasKit's EMBind objects are released with `delete()`; the RN Skia API (and
// therefore the shared renderers) calls `dispose()`.
const DISPOSABLE = [
'Image', 'Surface', 'Paint', 'Shader', 'ImageFilter', 'ColorFilter',
'Typeface', 'Font', 'RuntimeEffect', 'Path', 'Canvas', 'BlendMode',
];
function patchPrototypes(canvasKit: any): void {
for (const name of DISPOSABLE) {
const proto = canvasKit[name]?.prototype;
if (proto && typeof proto.delete === 'function' && typeof proto.dispose !== 'function') {
proto.dispose = function dispose() { this.delete(); };
}
}
// frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke).
const setStyle = canvasKit.Paint.prototype.setStyle;
canvasKit.Paint.prototype.setStyle = function (style: any) {
return setStyle.call(
this,
typeof style === 'number'
? (style === 1 ? canvasKit.PaintStyle.Stroke : canvasKit.PaintStyle.Fill)
: style
);
};
}
export interface InitSkiaOptions {
// Browser: path of canvaskit.wasm (Vite serves it as an asset). Node: omitted.
locateFile?: (file: string) => string;
}
function rasterSurface(w: number, h: number) {
return CK.MakeSurface(w, h);
}
// ponytail: one OffscreenCanvas per surface. The pipeline holds at most ~3 at
// once, far under the browser's ~16 live WebGL contexts; add a size-keyed pool
// if batch rendering ever keeps more alive.
function gpuSurface(w: number, h: number) {
if (typeof OffscreenCanvas === 'undefined') return null;
try {
const canvas = new OffscreenCanvas(w, h);
const surf = CK.MakeWebGLCanvasSurface(canvas);
if (!surf) return null;
const dispose = surf.dispose.bind(surf);
surf.dispose = () => {
try {
dispose();
} finally {
canvas.width = 0;
canvas.height = 0;
}
};
return surf;
} catch {
return null; // no GL context available — caller falls back to raster
}
}
export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
if (Skia) return Skia;
const canvasKit = await (CanvasKitInit as any)(
options.locateFile ? { locateFile: options.locateFile } : undefined
);
CK = canvasKit;
patchPrototypes(canvasKit);
Skia = {
// --- geometry / color ---------------------------------------------------
XYWHRect: (x: number, y: number, w: number, h: number) => canvasKit.XYWHRect(x, y, w, h),
Point: (x: number, y: number) => [x, y],
Color: (value: string) =>
value === 'transparent'
? canvasKit.Color(0, 0, 0, 0)
: canvasKit.parseColorString(value),
Color4f: (r: number, g: number, b: number, a: number) => canvasKit.Color4f(r, g, b, a),
// --- factories ----------------------------------------------------------
Paint: () => new canvasKit.Paint(),
Font: (typeface: any, size: number) => new canvasKit.Font(typeface, size),
Data: {
// CanvasKit exposes no Data class; callers pass bytes straight through.
fromBytes: (bytes: Uint8Array) => bytes,
},
Image: {
MakeImageFromEncoded: (data: Uint8Array) =>
canvasKit.MakeImageFromEncoded(
data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer)
),
},
Typeface: {
MakeFreeTypeFaceFromData: (data: ArrayBuffer) =>
canvasKit.Typeface.MakeFreeTypeFaceFromData(data),
},
ColorFilter: {
MakeMatrix: (m: number[]) => canvasKit.ColorFilter.MakeMatrix(m),
MakeCompose: (outer: any, inner: any) => canvasKit.ColorFilter.MakeCompose(outer, inner),
},
ImageFilter: {
MakeBlur: (sx: number, sy: number, tile: any, input: any) =>
canvasKit.ImageFilter.MakeBlur(sx, sy, tile, input ?? null),
MakeCompose: (outer: any, inner: any) =>
canvasKit.ImageFilter.MakeCompose(outer ?? null, inner ?? null),
MakeColorFilter: (cf: any, input: any) =>
canvasKit.ImageFilter.MakeColorFilter(cf, input ?? null),
},
Shader: {
MakeRadialGradient: (
center: number[], radius: number, colors: any[], pos: number[], mode: any
) => canvasKit.Shader.MakeRadialGradient(center, radius, colors, pos, mode),
},
RuntimeEffect: {
Make: (sksl: string) => canvasKit.RuntimeEffect.Make(sksl),
},
Surface: {
// A WebGL-backed surface is not a nicety here, it is the difference
// between usable and unusable: the same 12MP pipeline that finishes in
// well under a second on a GL surface took 3.2s for ONE tone pass and
// 13.7s for the CLARITY tap in the Node CPU raster benchmark. Raster is
// the fallback for a context-less environment (Node spikes, tests).
Make: (w: number, h: number) => rasterSurface(w, h),
MakeOffscreen: (w: number, h: number) => gpuSurface(w, h) ?? rasterSurface(w, h),
},
FontMgr: {
System: () => fontMgr,
},
// --- enums --------------------------------------------------------------
BlendMode: canvasKit.BlendMode,
TileMode: canvasKit.TileMode,
FilterMode: canvasKit.FilterMode,
MipmapMode: canvasKit.MipmapMode,
FontStyle: canvasKit.FontStyle,
ImageFormat: canvasKit.ImageFormat,
ColorType: canvasKit.ColorType,
ColorSpace: canvasKit.ColorSpace,
AlphaType: canvasKit.AlphaType,
PaintStyle: canvasKit.PaintStyle,
};
fontMgr = makeFontMgr(canvasKit);
return Skia;
}
// Named-family lookup for the custom watermark's FONT option. Only faces the
// bundle ships can be matched on web; anything else falls back to the caller's
// default typeface — the honest behaviour when there is no OS font service.
let fontMgr: any = null;
function makeFontMgr(canvasKit: any) {
const provider = canvasKit.TypefaceFontProvider.Make();
return {
registerFace(typeface: any, familyName: string) {
provider.registerFont(typeface, familyName);
},
matchFamilyStyle(family: string) {
if (!family) return null;
try {
return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null;
} catch {
return null;
}
},
};
}