scripts/motionqa.mjs
scripts/motionqa.mjsBrowse 27 files
2,306 tokens
8,444 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * motionqa.mjs — motion/perf/audio gate for Tier-1 scenes4 * (scroll state-machine, audio-reactive, 2.5D composite, scrubbed video).5 * Screenshots (shoot.mjs) are blind to time; this scrolls the page under CPU throttle and asserts6 * FPS, long-tasks, audio gating, and WebGL console health.7 * Usage: node motionqa.mjs <url> [--headed] [--dpr 2] [--throttle 4] [--min-fps 50] [--max-longtask 50] [--json]8 * --headed is not optional for a real number on any GPU-dependent page, and a headless run9 * reporting 0 console errors is not a pass — a headed run has caught a 404 the headless one missed.10 * Measure a PRODUCTION BUILD: a dev server costs roughly double per frame (HMR client, unminified11 * bundles, no asset pipeline), so its numbers describe a page nobody will ever load.12 * ponytail: reuses the playwright install shoot.mjs already needs; no new deps.13 */14import { chromium } from 'playwright';15 16const argv = process.argv.slice(2);17const url = argv.find(a => !a.startsWith('-'));18const opt = (name, def) => { const i = argv.indexOf('--' + name); return i >= 0 && argv[i + 1] ? +argv[i + 1] : def; };19const jsonMode = argv.includes('--json');20if (!url) {21 process.stderr.write('Usage: node motionqa.mjs <url> [--headed] [--throttle 4] [--min-fps 50] [--max-longtask 50] [--json]\n');22 process.exit(2);23}24const THROTTLE = opt('throttle', 4), MIN_FPS = opt('min-fps', 50), MAX_LT = opt('max-longtask', 50);25const DPR = opt('dpr', 2) || 2; // `|| 2` so a typo'd --dpr becomes the honest default, not a NaN viewport26const headed = argv.includes('--headed'); // headless chromium has NO GPU (software swiftshader) — WebGL FPS there is a floor, not the real number27 28const browser = await chromium.launch({ headless: !headed });29// 1440x900 @2x = 5.2 megapixels: a retina laptop, which is what a page like this gets judged on.30// The default DPR 1 renders 1.3MP, and fullscreen post-processing (bloom, DoF, grain, any full-frame31// shader) costs per pixel — so at DPR 1 the expensive part of the frame is a quarter of its real cost32// and the gate says 60fps about a page that stutters on the reviewer's MacBook. Fillrate is the budget,33// not geometry. --dpr 1 is for reproducing an old measurement, not for judging a scene.34const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: DPR });35const page = await context.newPage();36const consoleErrors = [];37page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });38page.on('pageerror', e => consoleErrors.push(String(e)));39 40await page.goto(url, { waitUntil: 'load' });41 42// audio/video must be silent on load — sound starts on a user gesture, never before43const audioAutoplaying = await page.evaluate(() =>44 [...document.querySelectorAll('audio,video')].some(a => !a.paused && !a.muted && a.volume > 0));45const hasCanvas = await page.evaluate(() => !!document.querySelector('canvas'));46 47// A dev server costs roughly 2x per frame vs its own production build (HMR client, unminified48// bundles, on-the-fly transforms, no image pipeline). That direction only ever produces FALSE49// FAILURES — but a false FAIL sends you optimizing a scene that was already fast, which is how a50// motion budget gets cut for nothing. Detect the three common dev clients and say so out loud.51const devServer = await page.evaluate(() => !!(52 document.querySelector('script[src*="/@vite/client"]') ||53 window.__vite_plugin_react_preamble_installed__ ||54 window.webpackHotUpdate || window.__webpack_hmr ||55 window.next?.router?.isDevBuild || window.__NEXT_DATA__?.buildId === 'development'56));57 58// warm up: let CDN modules load, shaders compile, first textures upload — we measure STEADY STATE,59// not the cold-start frame. (Headless chromium renders WebGL via software swiftshader, so for WebGL60// scenes also prefer a headed/GPU run or a lower --throttle; software raster is not representative.)61await page.evaluate(() => new Promise(r => {62 scrollTo(0, document.body.scrollHeight);63 setTimeout(() => { scrollTo(0, 0); setTimeout(r, 500); }, 900);64}));65 66const cdp = await page.context().newCDPSession(page);67await cdp.send('Emulation.setCPUThrottlingRate', { rate: THROTTLE });68 69// `buffered: true` replays every long task since navigation — bundle parse, shader compile,70// PMREM prefilter — into a number the rubric defines as "during the scroll". Observe live only,71// after warm-up, and report load-time separately so both are visible and neither is a false FAIL.72await page.evaluate(() => {73 window.__lt = 0; window.__ltLoad = 0;74 try {75 new PerformanceObserver(l => { for (const e of l.getEntries()) window.__ltLoad = Math.max(window.__ltLoad, e.duration); })76 .observe({ type: 'longtask', buffered: true });77 new PerformanceObserver(l => { for (const e of l.getEntries()) window.__lt = Math.max(window.__lt, e.duration); })78 .observe({ type: 'longtask' });79 } catch { /* longtask unsupported */ }80});81 82// slow full-page scroll over ~4s, sampling rAF deltas for the worst (min) FPS83const minFps = await page.evaluate(() => new Promise(res => {84 let last = performance.now(), min = 999, end = last + 4000, seen = 0;85 (function tick(t) {86 const d = t - last; last = t;87 if (d > 0) { min = Math.min(min, 1000 / d); seen++; }88 scrollBy(0, innerHeight / 60);89 t < end ? requestAnimationFrame(tick) : res(seen > 10 ? min : 60);90 })(performance.now());91}));92const maxLongTask = await page.evaluate(() => window.__lt || 0);93const loadLongTask = await page.evaluate(() => window.__ltLoad || 0);94await browser.close();95 96const fails = [], advisories = [];97// headless chromium has no GPU → software swiftshader inflates WebGL FPS + raster long-tasks.98// Under headless+canvas those two are ADVISORIES (run --headed / on-device for a real number); the99// rest (audio gating, console/WebGL errors) are GPU-independent and stay hard fails.100const softWebgl = hasCanvas && !headed;101const perf = (cond, msg) => { if (cond) (softWebgl ? advisories : fails).push(msg + (softWebgl ? ` [headless software ${hasCanvas ? 'WebGL' : 'rasterization'} — run --headed for a real number]` : '')); };102perf(minFps < MIN_FPS, `minFps ${minFps.toFixed(0)} < ${MIN_FPS} @${THROTTLE}x throttle`);103perf(maxLongTask > MAX_LT, `long task ${maxLongTask.toFixed(0)}ms > ${MAX_LT}ms`);104if (audioAutoplaying) fails.push('audio/video playing with sound on load (must be gesture-gated)');105const ctxErr = consoleErrors.filter(e => /WebGL|Context Lost|Too many active/i.test(e));106if (ctxErr.length) fails.push(`WebGL context error in console: ${ctxErr[0].slice(0, 80)}`);107 108// Load-time long tasks are real information (bundle parse, shader compile, PMREM prefilter) but109// they are not what the rubric row asks about, so they are reported, never failed on.110if (loadLongTask > MAX_LT) advisories.push(`load-time long task ${loadLongTask.toFixed(0)}ms (bundle parse / shader compile) — not a scroll fail, but it delays interactivity`);111if (devServer) advisories.push('measured against a DEV SERVER — a production build runs roughly 2x faster per frame; re-measure on the built output before you cut anything from the scene');112const megapixels = +((1440 * 900 * DPR * DPR) / 1e6).toFixed(1);113const summary = {114 minFps: +minFps.toFixed(0), maxLongTask: +maxLongTask.toFixed(0), loadLongTask: +loadLongTask.toFixed(0),115 dpr: DPR, megapixels, devServer,116 audioAutoplaying, consoleErrors: consoleErrors.length, headed, softWebgl, fails, advisories,117};118if (jsonMode) {119 process.stdout.write(JSON.stringify(summary, null, 2) + '\n');120} else {121 // DPR belongs in the headline number: "minFps 57" means nothing without the pixel count behind it,122 // and this line is what gets quoted verbatim into the QA sheet.123 process.stdout.write(`motionqa: minFps ${summary.minFps} @${THROTTLE}x CPU - 1440x900@${DPR}x (${megapixels}MP) - scroll long-task ${summary.maxLongTask}ms (load ${summary.loadLongTask}ms) - audio ${audioAutoplaying ? 'AUTOPLAYING(!)' : 'gesture-gated'} - console errors ${consoleErrors.length}${softWebgl ? ' [headless/software-WebGL]' : ''}${devServer ? ' [DEV SERVER]' : ''}\n`);124 for (const a of advisories) process.stdout.write(`ADVISORY ${a}\n`);125 for (const f of fails) process.stdout.write(`FAIL ${f}\n`);126 if (!fails.length) process.stdout.write(advisories.length ? 'PASS (perf advisories — verify headed)\n' : 'PASS\n');127}128process.exit(fails.length ? 1 : 0);129