// scripts/bake-thumbnails.ts
//
// Captures a screenshot of every built-in preset to public/preset-thumbs/.
// Used to ship cached thumbnails so a fresh visitor sees the right image
// for each preset card without having to load + run each preset for ~4 s.
//
// Workflow:
// 1. Start dev server externally (or rely on Playwright's webServer
// config — but we run this as a bare script, not a test).
// 2. For each preset: navigate to /?preset=<id>, wait WAIT_MS for the
// simulation to settle, take a canvas screenshot, save as PNG.
//
// Run: npx tsx scripts/bake-thumbnails.ts
// (requires a running `npm run dev` on :5173)
//
// The thumbnails are deliberately rendered by the real engine — not faked
// — so the cached images stay faithful to the preset's actual behaviour.
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { type Page, chromium } from '@playwright/test';
import { BUILT_IN_PRESETS } from '../src/presets/builtIn';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../public/preset-thumbs');
const APP_URL = process.env.PHYSARUM_URL ?? 'http://localhost:5173';
const WAIT_MS = 4500; // ~270 frames @ 60 fps — past AUTO_SNAPSHOT_FRAMES (240)
const THUMB_LONG_EDGE = 480;
async function capture(page: Page, id: string): Promise<void> {
const url = `${APP_URL}/?preset=${encodeURIComponent(id)}&bare=1`;
await page.goto(url, { waitUntil: 'load' });
// Let the engine boot + select the preset + run the sim for a while
await page.waitForTimeout(WAIT_MS);
// Capture by screenshotting the canvas element directly (not by
// drawImage'ing the WebGPU canvas into a 2D context — that path turns
// out to race the swap chain in headless mode and yields blank PNGs).
// Browser-side screenshot reads the actual presented framebuffer.
const canvasEl = page.locator('canvas');
const box = await canvasEl.boundingBox();
if (!box) throw new Error(`canvas not visible for preset ${id}`);
const outPath = resolve(OUT_DIR, `${id}.png`);
await page.screenshot({
path: outPath,
clip: { x: box.x, y: box.y, width: box.width, height: box.height },
omitBackground: false,
});
// Downscale to the target long edge so the shipped PNGs stay small.
// sharp would be cleaner but we don't have it; reuse the page's 2D
// canvas to resize and re-encode.
const dataUrl = await page.evaluate(
async ([pngBase64, longEdge]: [string, number]) => {
const img = new Image();
img.src = `data:image/png;base64,${pngBase64}`;
await new Promise<void>((res, rej) => {
img.onload = () => res();
img.onerror = () => rej(new Error('image decode failed'));
});
const sw = img.naturalWidth;
const sh = img.naturalHeight;
const scale = longEdge / Math.max(sw, sh);
const w = Math.max(1, Math.round(sw * scale));
const h = Math.max(1, Math.round(sh * scale));
const off = document.createElement('canvas');
off.width = w;
off.height = h;
const ctx = off.getContext('2d');
if (!ctx) throw new Error('no 2d context');
ctx.drawImage(img, 0, 0, w, h);
return off.toDataURL('image/png');
},
[(await import('node:fs')).readFileSync(outPath).toString('base64'), THUMB_LONG_EDGE] as [
string,
number,
],
);
const b64 = dataUrl.replace(/^data:image\/png;base64,/, '');
writeFileSync(outPath, Buffer.from(b64, 'base64'));
console.log(` ✓ ${id}.png`);
}
async function main(): Promise<void> {
mkdirSync(OUT_DIR, { recursive: true });
// Headed mode required on macOS — Chrome's headless renderer doesn't
// expose a working WebGPU device, so headless captures end up as blank
// (or topbar-only) screenshots. We minimise the disruption by giving
// the window a small viewport.
const browser = await chromium.launch({
headless: false,
args: ['--enable-unsafe-webgpu', '--enable-features=Vulkan'],
});
const ctx = await browser.newContext({
viewport: { width: 960, height: 720 },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
// Optional subset: PHYSARUM_PRESET_IDS=aurora,mandala,… bakes only those (avoids re-rendering the
// unchanged presets — the sim is stochastic, so a full re-bake would churn every PNG).
const only = process.env.PHYSARUM_PRESET_IDS?.split(',')
.map((s) => s.trim())
.filter(Boolean);
const presets = only ? BUILT_IN_PRESETS.filter((p) => only.includes(p.id)) : BUILT_IN_PRESETS;
console.log(`Baking ${presets.length} preset thumbnails → ${OUT_DIR}`);
try {
for (const preset of presets) {
await capture(page, preset.id);
}
} finally {
await ctx.close();
await browser.close();
}
console.log('Done.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});