Add CanvasKit web engine spike + W0 findings (recipes-web)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user