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
+99
View File
@@ -0,0 +1,99 @@
// Per-step cost of the web pipeline, measured at three sizes. The 12MP run of
// spike/w0.ts was unusably slow; this isolates WHICH pass eats the time.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { initSkia, Skia } from '../src/engine/skiaShim';
import { CLARITY_SKSL } from '../../src/utils/toneShader';
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, '../..');
const SAMPLE = process.env.W0_SAMPLE ?? '/tmp/w0/p2.jpg';
function time(label: string, fn: () => void): number {
const t = performance.now();
fn();
const ms = performance.now() - t;
console.log(` ${label.padEnd(26)} ${ms.toFixed(0)}ms`);
return ms;
}
async function main() {
await initSkia();
const bytes = new Uint8Array(fs.readFileSync(SAMPLE));
const src = Skia.Image.MakeImageFromEncoded(bytes);
console.log(`source ${src.width()}x${src.height()}`);
const full = { w: src.width(), h: src.height() };
const sizes = [
{ name: '1024', w: 1024, h: Math.round((1024 * full.h) / full.w) },
{ name: '2048', w: 2048, h: Math.round((2048 * full.h) / full.w) },
{ name: 'full', w: full.w, h: full.h },
];
const sharpen = Skia.RuntimeEffect.Make(CLARITY_SKSL);
const tone = Skia.RuntimeEffect.Make(
'uniform shader src; uniform float a; vec4 main(vec2 p){ vec4 c = src.eval(p); return vec4(c.rgb*(1.0+a), c.a);}'
);
for (const s of sizes) {
console.log(`\n[sample downscaled to ${s.w}x${s.h}]`);
const surf = Skia.Surface.Make(s.w, s.h);
const canvas = surf.getCanvas();
const paint = Skia.Paint();
time('draw downscaled image', () => {
canvas.drawImageRect(
src, Skia.XYWHRect(0, 0, full.w, full.h), Skia.XYWHRect(0, 0, s.w, s.h), paint
);
});
const shader = src.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
const toneShader = tone.makeShaderWithChildren([0.2], [shader]);
time('tone skSL pass', () => {
const p = Skia.Paint();
p.setShader(toneShader);
canvas.drawRect(Skia.XYWHRect(0, 0, s.w, s.h), p);
p.dispose();
});
time('clarity skSL 5-tap', () => {
const snap = surf.makeImageSnapshot();
const child = snap.makeShaderOptions(
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
);
const sh = sharpen.makeShaderWithChildren([0.3, 1, 1], [child]);
const p = Skia.Paint();
p.setShader(sh);
canvas.drawRect(Skia.XYWHRect(0, 0, s.w, s.h), p);
p.dispose(); sh.dispose(); child.dispose(); snap.dispose();
});
time('blur sigma=40 (imagefilter)', () => {
const snap = surf.makeImageSnapshot();
const p = Skia.Paint();
p.setImageFilter(Skia.ImageFilter.MakeBlur(40, 40, Skia.TileMode.Clamp, null));
canvas.clear(Skia.Color('transparent'));
canvas.drawImage(snap, 0, 0, p);
p.dispose(); snap.dispose();
});
time('jpeg q95 encode', () => {
const snap = surf.makeImageSnapshot();
snap.encodeToBytes(Skia.ImageFormat.JPEG, 95);
snap.dispose();
});
surf.dispose();
}
// Full-res decode cost (what happens once per loaded photo).
time('decode 12MP jpeg', () => {
Skia.Image.MakeImageFromEncoded(bytes);
});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
// Drives spike/gpu.html in headless Chromium: is a WebGL-backed CanvasKit
// surface fast enough to replace the CPU raster path (which measured 3.2s for a
// single 12MP tone pass in Node)? Run:
// node spike/gpu-bench.mjs
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
const here = path.dirname(fileURLToPath(import.meta.url));
const webRoot = path.resolve(here, '..');
const SAMPLES = process.env.W0_SAMPLES ?? '/tmp/w0';
const CK_BIN = path.join(webRoot, 'node_modules/canvaskit-wasm/bin');
const routes = [
{ prefix: '/bin/', dir: CK_BIN },
{ prefix: '/samples/', dir: SAMPLES },
{ prefix: '/', dir: path.join(webRoot, 'spike') },
];
const MIME = { '.js': 'text/javascript', '.wasm': 'application/wasm', '.html': 'text/html', '.jpg': 'image/jpeg', '.png': 'image/png' };
const server = http.createServer((req, res) => {
const url = decodeURIComponent(req.url.split('?')[0]);
const route = routes.find((r) => url.startsWith(r.prefix));
if (!route) { res.writeHead(404).end(); return; }
const rel = url.slice(route.prefix.length) || 'gpu.html';
const file = path.join(route.dir, rel);
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404).end(String(err)); return; }
res.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
res.end(data);
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
console.log(`serving on ${port}`);
const browser = await chromium.launch({
// The cached playwright build is chromium-1228 while playwright-core wants
// 1243; point straight at the cached binary instead of downloading one.
executablePath: process.env.CHROME_PATH ??
'/home/locpham/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox'],
});
const page = await browser.newPage();
page.on('console', (m) => console.log('[browser]', m.text()));
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
await page.goto(`http://127.0.0.1:${port}/gpu.html`, { waitUntil: 'load' });
await page.waitForFunction(() => document.title === 'done' || document.title === 'error', null, { timeout: 180000 });
console.log(await page.textContent('#out').catch(() => '(no output element)'));
const results = await page.evaluate(() => window.__results ?? null);
console.log('\njson:', JSON.stringify(results));
await browser.close();
server.close();
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>W0 GPU bench</title></head>
<body>
<pre id="out">running…</pre>
<script src="/bin/full/canvaskit.js"></script>
<script>
(async () => {
const CanvasKitInit = window.CanvasKitInit;
const log = (s) => { document.getElementById('out').textContent += '\n' + s; };
const results = {};
try {
const CK = await CanvasKitInit({ locateFile: (f) => '/bin/full/' + f });
const img = await fetch('/samples/p2.jpg').then((r) => r.arrayBuffer()).then((b) => new Uint8Array(b));
let t0 = performance.now();
const src = CK.MakeImageFromEncoded(img);
log(`decode 12MP: ${(performance.now() - t0).toFixed(0)}ms ${src.width()}x${src.height()}`);
const W = src.width();
const H = src.height();
const sizes = [
{ name: '1024', w: 1024, h: Math.round((1024 * H) / W) },
{ name: '2048', w: 2048, h: Math.round((2048 * H) / W) },
{ name: 'full', w: W, h: H },
];
const tone = CK.RuntimeEffect.Make(
'uniform shader src; uniform float a; vec4 main(vec2 p){ vec4 c = src.eval(p); return vec4(c.rgb*(1.0+a), c.a);}'
);
const sharpen = CK.RuntimeEffect.Make(
'uniform shader img; uniform float2 texel; uniform float amount; vec4 main(vec2 p){ vec4 c=img.eval(p); vec4 n=img.eval(p+float2(0.0,-texel.y)); vec4 s=img.eval(p+float2(0.0,texel.y)); vec4 w=img.eval(p+float2(-texel.x,0.0)); vec4 e=img.eval(p+float2(texel.x,0.0)); return vec4(clamp(c.rgb*(1.0+4.0*amount)-amount*(n.rgb+s.rgb+w.rgb+e.rgb),0.0,1.0), c.a);}'
);
log('runtime effects: tone=' + !!tone + ' sharpen=' + !!sharpen);
for (const s of sizes) {
log(`\n[webgl canvas ${s.w}x${s.h}]`);
const canvas = new OffscreenCanvas(s.w, s.h);
const gl0 = canvas.getContext('webgl2');
const dbg = gl0 && gl0.getExtension('WEBGL_debug_renderer_info');
if (dbg) log(' GL: ' + gl0.getParameter(dbg.UNMASKED_RENDERER_WEBGL));
t0 = performance.now();
const surface = CK.MakeWebGLCanvasSurface(canvas);
log(` MakeWebGLCanvasSurface: ${(performance.now() - t0).toFixed(0)}ms ok=${!!surface}`);
if (!surface) continue;
const cv = surface.getCanvas();
results[s.name] = {};
const shader = src.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
const toneShader = tone.makeShaderWithChildren([0.2], [shader]);
const draw = () => {
const p = new CK.Paint();
p.setShader(toneShader);
cv.drawRect(CK.XYWHRect(0, 0, s.w, s.h), p);
surface.flush();
p.delete();
};
draw(); // warm-up: shader compile + texture upload
const measure = (label, fn) => {
fn();
const t = performance.now();
fn();
const ms = performance.now() - t;
log(` ${label.padEnd(26)} ${ms.toFixed(0)}ms`);
results[s.name][label] = ms;
};
measure('tone skSL pass', draw);
measure('clarity skSL 5-tap', () => {
const snap = surface.makeImageSnapshot();
const child = snap.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
const sh = sharpen.makeShaderWithChildren([0.3, 1, 1], [child]);
const p = new CK.Paint();
p.setShader(sh);
cv.drawRect(CK.XYWHRect(0, 0, s.w, s.h), p);
surface.flush();
p.delete(); sh.delete(); child.delete(); snap.delete();
});
measure('blur sigma=40', () => {
const snap = surface.makeImageSnapshot();
const p = new CK.Paint();
p.setImageFilter(CK.ImageFilter.MakeBlur(40, 40, CK.TileMode.Clamp, null));
cv.clear(CK.Color(0, 0, 0, 0));
cv.drawImage(snap, 0, 0, p);
surface.flush();
p.delete(); snap.delete();
});
let enc = -1;
measure('jpeg q95 encode', () => {
const snap = surface.makeImageSnapshot();
const b = snap.encodeToBytes(CK.ImageFormat.JPEG, 95);
enc = b ? b.length : -1;
snap.delete();
});
log(' encoded bytes: ' + enc);
surface.delete();
}
window.__results = results;
log('\nDONE');
document.title = 'done';
} catch (e) {
log('ERROR ' + (e && e.stack ? e.stack : e));
document.title = 'error';
}
})();
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
import CanvasKitInit from 'canvaskit-wasm';
const CK = await CanvasKitInit();
const has = (o, k) => typeof o?.[k];
console.log('CK version', CK.version ?? '?');
console.log('ImageFilter keys:', Object.keys(CK.ImageFilter).join(','));
console.log('ColorFilter keys:', Object.keys(CK.ColorFilter).join(','));
console.log('Shader keys:', Object.keys(CK.Shader).join(','));
console.log('Typeface keys:', Object.keys(CK.Typeface).join(','));
console.log('Surface keys:', Object.keys(CK.Surface).join(','));
console.log('RuntimeEffect keys:', Object.keys(CK.RuntimeEffect).join(','));
console.log('Paint proto keys:', Object.getOwnPropertyNames(CK.Paint.prototype).join(','));
console.log('Canvas draw*:', Object.getOwnPropertyNames(CK.Canvas.prototype).filter(n=>n.startsWith('draw')).join(','));
console.log('hasImageFilter', has(CK,'ImageFilter'), 'hasX', has(CK,'XYWHRect'), 'hasColor', has(CK,'Color'));
console.log('MakeBlur', has(CK.ImageFilter,'MakeBlur'), 'MakeCompose', has(CK.ImageFilter,'MakeCompose'), 'MakeMatrixConvolution', has(CK.ImageFilter,'MakeMatrixConvolution'));
+34
View File
@@ -0,0 +1,34 @@
import CanvasKitInit from 'canvaskit-wasm/full';
import fs from 'node:fs';
const CK = await CanvasKitInit();
console.log('MakeSurface', typeof CK.MakeSurface, 'MakeWebGLSurface', typeof CK.MakeWebGLSurface, 'Font', typeof CK.Font, 'TypefaceFontProvider', typeof CK.TypefaceFontProvider, 'MakeManagedAnimation', typeof CK.MakeManagedAnimation);
console.log('top keys sample:', Object.keys(CK).filter(k=>/^Make|Font|Typeface|Paragraph/.test(k)).join(','));
const surf = CK.MakeSurface(400, 200);
console.log('surface', !!surf);
const c = surf.getCanvas();
c.clear(CK.Color(255,255,255,255));
const p = new CK.Paint();
p.setColor(CK.Color(20,20,20,255));
const ttf = fs.readFileSync('../assets/Cousine-Regular.ttf');
const ab = ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset+ttf.byteLength);
const tf = CK.Typeface.MakeFreeTypeFaceFromData(ab);
console.log('typeface', !!tf, tf && tf.getGlyphIDs('AB')[0] !== undefined);
const font = new CK.Font(tf, 32);
c.drawText('RecipesCam ABC 0123', 20, 80, p, font);
const emoji = fs.readFileSync('../assets/NotoEmoji-GPS.ttf');
const eab = emoji.buffer.slice(emoji.byteOffset, emoji.byteOffset+emoji.byteLength);
const etf = CK.Typeface.MakeFreeTypeFaceFromData(eab);
console.log('emoji typeface', !!etf);
if (etf) { const ef = new CK.Font(etf, 32); c.drawText('\u{1F4CD} \u{1F4F7}', 20, 140, p, ef); }
const img = surf.makeImageSnapshot();
const bytes = img.encodeToBytes(CK.ImageFormat.JPEG, 95);
console.log('jpeg bytes', bytes && bytes.length);
fs.writeFileSync('/tmp/ck-text.jpg', Buffer.from(bytes));
// runtime effect with child shader
const eff = CK.RuntimeEffect.Make(`
uniform shader img; uniform float2 texel; uniform float amount;
half4 main(float2 p){ half4 c = img.eval(p); half4 n = img.eval(p+float2(0.0,-texel.y)); half4 s = img.eval(p+float2(0.0,texel.y)); half4 w = img.eval(p+float2(-texel.x,0.0)); half4 e = img.eval(p+float2(texel.x,0.0)); return c + amount*(4.0*c-n-s-w-e); }`);
console.log('runtime effect', !!eff, eff && eff.getUniformCount?.());
const imgSh = img.makeShaderOptions(CK.TileMode.Clamp, CK.TileMode.Clamp, CK.FilterMode.Linear, CK.MipmapMode.None);
const sh = eff && eff.makeShaderWithChildren([1/400, 1/200, 1.0], [imgSh]);
console.log('shader', !!sh);
+10
View File
@@ -0,0 +1,10 @@
import CanvasKitInit from 'canvaskit-wasm';
const CK = await CanvasKitInit();
console.log('ImageFormat', JSON.stringify(CK.ImageFormat));
const surf = CK.MakeSurface(64, 64);
const c = surf.getCanvas(); c.clear(CK.Color(255,0,0,255));
const img = surf.makeImageSnapshot();
for (const k of Object.keys(CK.ImageFormat)) {
try { const b = img.encodeToBytes(CK.ImageFormat[k], 90); console.log(k, b ? b.length : null); } catch(e){ console.log(k, 'ERR', String(e).slice(0,80)); }
}
console.log('columns/readPixels', typeof img.readPixels, typeof img.encodeToBytes);
+11
View File
@@ -0,0 +1,11 @@
import CanvasKitInit from 'canvaskit-wasm/full';
import fs from 'node:fs';
const CK = await CanvasKitInit();
console.log('ImageFormat', JSON.stringify(CK.ImageFormat));
const surf = CK.MakeSurface(64, 64);
const c = surf.getCanvas(); c.clear(CK.Color(255,0,0,255));
const img = surf.makeImageSnapshot();
for (const k of Object.keys(CK.ImageFormat)) { try { const b = img.encodeToBytes(CK.ImageFormat[k], 90); console.log(k, b ? b.length : null); } catch(e){ console.log(k,'ERR'); } }
const ttf = fs.readFileSync('../assets/Cousine-Regular.ttf');
const tf = CK.Typeface.MakeFreeTypeFaceFromData(ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset+ttf.byteLength));
console.log('freetype', !!tf, 'drawText', typeof CK.Canvas.prototype.drawText, 'MakeSurface', typeof CK.MakeSurface);
+18
View File
@@ -0,0 +1,18 @@
import CanvasKitInit from 'canvaskit-wasm/full';
import fs from 'node:fs';
const CK = await CanvasKitInit();
const load = (p) => { const b = fs.readFileSync(p); return CK.Typeface.MakeFreeTypeFaceFromData(b.buffer.slice(b.byteOffset, b.byteOffset+b.byteLength)); };
const tf = load('../assets/Cousine-Regular.ttf');
const etf = load('../assets/NotoEmoji-GPS.ttf');
const s = '📍📷';
console.log('cousine glyphs', JSON.stringify(tf.getGlyphIDs(s)));
console.log('emoji glyphs ', JSON.stringify(etf.getGlyphIDs(s)));
console.log('textWidth', tf.getTextWidth?.('RECIPESCAM', 24), etf.getTextWidth?.('📍', 24));
const surf = CK.MakeSurface(200, 100); const c = surf.getCanvas();
c.clear(CK.Color(0,0,0,255));
const p = new CK.Paint(); p.setColor(CK.Color(255,255,255,255));
const f = new CK.Font(etf, 48);
c.drawText(s, 10, 60, p, f);
const px = surf.makeImageSnapshot().readPixels(0, 0, {width:200,height:100,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB});
let lit = 0; for (let i=0;i<px.length;i+=4) if (px[i]>128) lit++;
console.log('lit pixels in emoji draw:', lit);
+175
View File
@@ -0,0 +1,175 @@
// W0 parity/perf spike: run the real web render pipeline in Node (no browser)
// over sample photos and report time + determinism. Usage:
// W0_SAMPLES=<dir with .jpg> W0_OUT=<dir> corepack pnpm exec tsx spike/w0.ts
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { initSkia, Skia } from '../src/engine/skiaShim';
import { renderPhoto, type RenderAssets } from '../src/engine/exportEngine';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from '../../src/utils/defaultRecipes';
import type { FrameId, Recipe } from '../../src/types';
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, '../..');
const SAMPLE_DIR = process.env.W0_SAMPLES ?? '/tmp/w0';
const OUT_DIR = process.env.W0_OUT ?? '/tmp/w0-out';
const ab = (p: string): ArrayBuffer => {
const b = fs.readFileSync(p);
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer;
};
const assets: RenderAssets = {
textFont: ab(path.join(repo, 'assets/Cousine-Regular.ttf')),
emojiFont: ab(path.join(repo, 'assets/NotoEmoji-GPS.ttf')),
wallframe: new Uint8Array(fs.readFileSync(path.join(repo, 'wallframe.png'))),
};
// One recipe per film sim + a "everything on" torture case + a cinema grade.
const heavy: Recipe = {
id: 'w0-heavy',
name: 'W0 HEAVY',
frameId: 'none',
useGeotag: true,
baseFilter: 'classic-neg',
cinema: 'autumn',
adjustments: {
...DEFAULT_ADJUSTMENTS,
exposure: 0.6,
exposureCompensation: 2,
contrast: 12,
saturation: 8,
vibrance: 6,
highlight: -4,
shadow: 5,
temperature: 6100,
tint: 3,
colorChrome: 'weak',
colorChromeBlue: 'weak',
denoise: 3,
clarity: 4,
sharpening: 3,
grain: 4,
hdf: 4,
vignette: 3,
},
};
const QUICK = process.env.W0_QUICK === '1';
const CASES: { label: string; recipe: Recipe; frame: FrameId; extra?: Record<string, unknown> }[] = [
...FILM_SIMS.map((s) => ({ label: `sim:${s.id}`, recipe: filmSimToRecipe(s), frame: 'none' as FrameId })),
{ label: 'heavy', recipe: heavy, frame: 'none' },
{ label: 'heavy+polaroid', recipe: heavy, frame: 'polaroid' },
{ label: 'heavy+classic', recipe: heavy, frame: 'classic-white' },
{
label: 'heavy+wallframe',
recipe: heavy,
frame: 'wallframe',
extra: {
gpsWatermark: { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true },
useGeotag: true,
},
},
{ label: 'heavy+wm-rot90', recipe: heavy, frame: 'none', extra: {
watermark: { text: 'RECIPESCAM', x: 0.5, y: 0.9 },
watermarkRotation: 90,
} },
];
const GPS = {
latitude: 21.0285,
longitude: 105.8542,
locality: 'HÀ NỘI',
timestamp: Date.now(),
};
async function main() {
const t0 = performance.now();
await initSkia();
console.log(`CanvasKit init: ${(performance.now() - t0).toFixed(0)}ms`);
fs.mkdirSync(OUT_DIR, { recursive: true });
const samples = fs.readdirSync(SAMPLE_DIR).filter((f) => /\.jpe?g$/i.test(f)).sort();
if (samples.length === 0) throw new Error(`no sample jpgs in ${SAMPLE_DIR}`);
console.log(`samples: ${samples.join(', ')}`);
// Stats on the encoded file: a render that silently produced a blank frame
// would still "succeed", so mean/stddev of the decoded output is the cheap
// proof it contains a picture.
const stats = (bytes: Uint8Array) => {
const img = Skia.Image.MakeImageFromEncoded(bytes);
if (!img) return 'decode-fail';
const w = img.width();
const h = img.height();
const px = img.readPixels(0, 0, {
width: w, height: h, colorType: Skia.ColorType.RGBA_8888,
alphaType: Skia.AlphaType.Unpremul, colorSpace: Skia.ColorSpace.SRGB,
});
img.dispose();
if (!px) return 'read-fail';
let sum = 0;
let sum2 = 0;
let n = 0;
for (let i = 0; i < px.length; i += 4) {
const l = 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
sum += l;
sum2 += l * l;
n++;
}
const mean = sum / n;
return `mean=${mean.toFixed(1)} sd=${Math.sqrt(Math.max(0, sum2 / n - mean * mean)).toFixed(1)}`;
};
const rows: string[] = [];
for (const sample of samples) {
const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, sample)));
const base = sample.replace(/\.jpe?g$/i, '');
const cases = QUICK ? CASES.slice(0, 2).concat(CASES.filter((x) => /heavy/.test(x.label)).slice(0, 2)) : CASES;
for (const c of cases) {
const dpi = 300;
const input = {
source,
recipe: c.recipe,
frameId: c.frame,
useGeotag: (c.extra?.useGeotag as boolean) ?? false,
gpsInfo: GPS,
options: {
dpi,
sharpen: true,
watermark: (c.extra?.watermark as never) ?? null,
watermarkRotation: (c.extra?.watermarkRotation as number) ?? 0,
gpsWatermark: (c.extra?.gpsWatermark as never) ?? null,
wallframeLandscape: false,
},
assets,
};
const t = performance.now();
const out = await renderPhoto(input);
const ms = performance.now() - t;
if (!out) {
console.log(`FAIL ${sample} ${c.label}`);
continue;
}
const file = path.join(OUT_DIR, `${base}__${c.label.replace(/[^a-z0-9+]/gi, '_')}.jpg`);
fs.writeFileSync(file, out.bytes);
const jfif = new DataView(out.bytes.buffer, out.bytes.byteOffset).getUint16(14, false);
rows.push(`${sample}\t${c.label}\t${out.width}x${out.height}\t${(out.bytes.length / 1024).toFixed(0)}KB\t${ms.toFixed(0)}ms\tdpi=${jfif}\t${stats(out.bytes)}`);
}
}
console.log('\nsample\tcase\tsize\tencoded\ttime\tdpi');
console.log(rows.join('\n'));
// Determinism: the same input twice must produce identical bytes (a renderer
// that is stable in Node is the baseline the browser worker must match).
const source = new Uint8Array(fs.readFileSync(path.join(SAMPLE_DIR, samples[0])));
const args = { source, recipe: heavy, frameId: 'none' as FrameId, assets };
const a = await renderPhoto(args);
const b = await renderPhoto(args);
console.log('\ndeterministic:', !!a && !!b && Buffer.compare(Buffer.from(a.bytes), Buffer.from(b.bytes)) === 0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});