// W0 parity/perf spike: run the real web render pipeline in Node (no browser) // over sample photos and report time + determinism. Usage: // W0_SAMPLES= W0_OUT= 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 }[] = [ ...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); });