import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import puppeteer from 'puppeteer-core'; /** * Boot the app in a headless browser, let it skate for a while, and report * back what happened: console errors, frame rate, and where everyone ended up. * * The point is not the screenshots — it is that a spike whose whole success * criterion is "does this look and run right" needs an answer that does not * depend on someone having the tab open. * * node tools/capture.mjs [seconds] */ const ROOT = path.resolve(import.meta.dirname, '..'); const OUT = path.join(ROOT, 'shots'); const PORT = 4181; const URL = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`; const SECONDS = Number(process.argv[2] ?? 12); function findChrome() { const cache = path.join(process.env.HOME, '.cache/puppeteer/chrome'); if (fs.existsSync(cache)) { const builds = fs.readdirSync(cache).sort().reverse(); for (const b of builds) { const exe = path.join(cache, b, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'); if (fs.existsSync(exe)) return exe; } } const system = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; if (fs.existsSync(system)) return system; throw new Error('no Chrome found — set CHROME_PATH'); } async function waitForServer(url, timeoutMs = 30000) { const deadline = Date.now() + timeoutMs; for (;;) { try { const res = await fetch(url); if (res.ok) return; } catch { // Vite is still starting. } if (Date.now() > deadline) throw new Error('vite did not come up at ' + url); await new Promise((r) => setTimeout(r, 250)); } } let server = null; let browser = null; try { fs.mkdirSync(OUT, { recursive: true }); if (!process.env.TILT_URL) { server = spawn( path.join(ROOT, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(PORT), '--strictPort'], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }, ); server.stderr.on('data', (d) => process.stderr.write('[vite] ' + d)); } await waitForServer(URL); browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH ?? findChrome(), headless: true, args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader', '--no-sandbox'], }); const page = await browser.newPage(); // deviceScaleFactor 2, not 1: the target is a retina Mac, and running this at // 1 hid a canvas-sizing bug that made the element twice the window on the // machine anyone actually looks at it on. await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 }); const errors = []; page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); }); page.on('pageerror', (err) => errors.push(String(err?.stack ?? err))); // 3v3 scrimmage is what the lineup / skating shots need; skip the menu. const captureUrl = new URL(URL); if (!captureUrl.searchParams.has('mode')) captureUrl.searchParams.set('mode', '3v3'); await page.goto(captureUrl.href, { waitUntil: 'domcontentloaded' }); // The boot overlay is removed once physics is up and the first frame ran. await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 }); // Mode auto-starts from ?mode=; wait until the match handle is live. await page.waitForFunction(() => window.tilt?.match, { timeout: 15000 }); // The canvas must fill the window exactly, at whatever pixel ratio. Checked // at two window sizes so a resize path that only works on first load fails // here rather than in someone's browser. for (const [w, h] of [[1280, 720], [900, 1000]]) { await page.setViewport({ width: w, height: h, deviceScaleFactor: 2 }); await new Promise((r) => setTimeout(r, 300)); const fit = await page.evaluate(() => { const c = document.getElementById('stage'); const r = c.getBoundingClientRect(); return { css: [Math.round(r.width), Math.round(r.height)], win: [window.innerWidth, window.innerHeight], buffer: [c.width, c.height], dpr: window.devicePixelRatio, }; }); const fits = fit.css[0] === fit.win[0] && fit.css[1] === fit.win[1]; console.log(`viewport ${w}x${h} @${fit.dpr}x: canvas ${fit.css.join('x')} css, ` + `${fit.buffer.join('x')} buffer — ${fits ? 'fills the window' : 'DOES NOT FIT'}`); if (!fits) { errors.push(`canvas ${fit.css.join('x')} does not fill window ${fit.win.join('x')}`); } } await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 }); await new Promise((r) => setTimeout(r, 300)); // The starting lineup, before anyone has moved: both teams in their own half. await page.evaluate(() => { window.tilt.match.reset(); Object.assign(window.tilt.cam.state, { mode: 'broadcast', distance: 46, pitch: 0.85 }); }); await new Promise((r) => setTimeout(r, 400)); await page.screenshot({ path: path.join(OUT, 'lineup.png') }); await page.evaluate(() => { window.tilt.match.reset(); Object.assign(window.tilt.cam.state, { distance: 34, pitch: 0.62 }); }); // Let them skate. Software rasterisation is slow, so this is wall-clock time // rather than a frame count — the sim is dt-driven and does not care. await new Promise((r) => setTimeout(r, SECONDS * 1000)); const hud = await page.$eval('#hud', (el) => el.textContent); await page.screenshot({ path: path.join(OUT, 'broadcast.png') }); /** Frame a shot through the debug handle and wait for the camera to settle. */ async function shot(name, camState, settleMs = 2500) { await page.evaluate((s) => Object.assign(window.tilt.cam.state, s), camState); await new Promise((r) => setTimeout(r, settleMs)); await page.screenshot({ path: path.join(OUT, name + '.png') }); } // Follow-cam: the only view that shows whether the stride and the direction // of travel actually agree. await shot('follow', { mode: 'follow', followIndex: 0, distance: 9, pitch: 0.28 }); // Close enough to judge the stance, the arm carry and the blade angle. await shot('closeup', { mode: 'follow', followIndex: 0, distance: 3.6, pitch: 0.16 }); // From the side, where a lean into a turn actually reads. await shot('side', { mode: 'follow', followIndex: 1, distance: 5.5, pitch: 0.1 }); // Fastest skater's numbers, so the shot can be read against real motion. const detail = await page.evaluate(() => window.tilt.match.states.map((s) => ({ speed: +Math.hypot(s.vx, s.vz).toFixed(2), effort: +s.effort.toFixed(2), gait: +window.tilt.match.skaters[s.id].animator.gait.toFixed(2), bank: +window.tilt.match.skaters[s.id].animator.bank.toFixed(2), state: window.tilt.match.skaters[s.id].animator.state, }))); console.log('HUD: ' + hud.replace(/\n/g, ' | ')); console.log('skaters: ' + JSON.stringify(detail)); console.log(`shots → ${path.relative(ROOT, OUT)}/`); if (errors.length) { console.error('\nbrowser errors:\n' + errors.join('\n')); process.exitCode = 1; } else { console.log('no console errors'); } } finally { await browser?.close(); server?.kill('SIGTERM'); }