173 lines
6.8 KiB
JavaScript
173 lines
6.8 KiB
JavaScript
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');
|
|
}
|