Zum Inhalt

scripts/bake-thumbnails.ts

// 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);
});