Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:28:11 -05:00
parent 65bfc3dcb4
commit 6c08153e42
63 changed files with 15790 additions and 1 deletions
+172
View File
@@ -0,0 +1,172 @@
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)));
await page.goto(URL, { waitUntil: 'domcontentloaded' });
// The boot overlay is removed once physics is up and the first frame ran.
await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
// 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');
}