Files
wizwar6e/deploy/verify-scenes.mjs
Eric WagonerandClaude Fable 5 58d1d34cb0 The render harness: every screenplay's beats pinned as golden frames
deploy/verify-scenes.sh replays each screenplay to its authored beats
under a paused Playwright clock — pulses, tweens, and fx advance only
on the harness's schedule, so the same beat renders the same bytes
every run — screenshots the reel, and diffs pixels against the goldens
in deploy/scene-goldens (30 frames: warp transits, impacts, curses
landing, in both views). A channel may drift 6 before a pixel counts,
0.2% of pixels before a beat fails; --update blesses intended changes.

Proven both ways before landing: a clean double-run reproduces all 30
goldens exactly, and resurrecting the warp-smear cutaway bug fails the
warp-transit beat at 61% pixel drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-09-01 16:58:43 -04:00

159 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Render-regression harness: every screenplay's pinned beats, screenshot
// under a paused Playwright clock, diffed pixel-by-pixel against the
// goldens in deploy/scene-goldens/. The clock is what makes the pixels
// reproducible — pulsing rings, tweens, and fx all advance only when the
// harness says so, so the same beat renders the same bytes every run.
//
// npx tsx deploy/verify-scenes.mjs # verify all scenes
// npx tsx deploy/verify-scenes.mjs --update # re-bless the goldens
// npx tsx deploy/verify-scenes.mjs the-rout # one scene (mixes with --update)
//
// Run from the repo root AFTER `npx vite build` in packages/web (the
// wrapper deploy/verify-scenes.sh does both). Needs the Playwright
// chromium cache (`npx playwright-core install chromium` if missing).
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
const args = process.argv.slice(2);
const update = args.includes("--update");
const only = args.filter((a) => !a.startsWith("--"));
const GOLDENS = join(import.meta.dirname, "scene-goldens");
const DIFFS = join(tmpdir(), "wizwar-scene-diffs");
const PORT = 8807;
// A channel may drift this far before a pixel counts as different, and
// this fraction of pixels may differ before a beat fails — headroom for
// raster jitter, far below any real regression.
const CHANNEL_TOL = 6;
const FAIL_FRAC = 0.002;
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
function chromiumExe() {
if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME;
const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright");
const builds = existsSync(cache)
? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort()
: [];
for (const build of builds.reverse()) {
const exe = join(cache, build, "chrome-mac-arm64",
"Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
if (existsSync(exe)) return exe;
}
throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)");
}
/** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */
function rgba(png) {
return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png,
"-f", "rawvideo", "-pix_fmt", "rgba", "-"], { maxBuffer: 64 * 1024 * 1024 });
}
function compare(goldenPng, actualPng) {
const a = rgba(goldenPng), b = rgba(actualPng);
if (a.length !== b.length) return { frac: 1, why: "size differs" };
let bad = 0;
for (let i = 0; i < a.length; i += 4) {
if (Math.abs(a[i] - b[i]) > CHANNEL_TOL ||
Math.abs(a[i + 1] - b[i + 1]) > CHANNEL_TOL ||
Math.abs(a[i + 2] - b[i + 2]) > CHANNEL_TOL) bad++;
}
return { frac: bad / (a.length / 4), why: null };
}
// --- A private server on a throwaway data dir. ---------------------------
const dataDir = mkdtempSync(join(tmpdir(), "wizwar-scenes-"));
const server = spawn("npx", ["tsx", "src/index.ts"], {
cwd: join(import.meta.dirname, "..", "packages", "server"),
env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") },
stdio: "ignore",
});
const up = async () => {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/`);
if (r.ok) return;
} catch { /* not yet */ }
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("server never came up");
};
let failures = 0;
try {
await up();
mkdirSync(GOLDENS, { recursive: true });
rmSync(DIFFS, { recursive: true, force: true });
mkdirSync(DIFFS, { recursive: true });
const browser = await chromium.launch({
executablePath: chromiumExe(),
args: ["--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none"],
});
const scenes = SCREENPLAYS.filter((sp) => !only.length || only.includes(sp.name));
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
for (const sp of scenes) {
// One page per view: the reel steps forward beat to beat under the
// paused clock, so a scene's later beats cost only their marginal
// steps — and every beat's simulated capture time is still exactly
// (steps so far) × 2.6s, identical every run.
const takes = [
{ view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) },
{ view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] },
];
for (const { view, beats } of takes) {
if (!beats.length) continue;
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
await page.clock.install({ time: EPOCH });
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
await page.locator("button", { hasText: "❚❚" }).click();
for (let i = 0; i < 4; i++) await page.locator("button", { hasText: "◀" }).click();
if (view === "fpv") await page.getByText("your eyes").click();
// Freeze the world at a fixed instant, then advance it by hand.
// Steps on the way to a beat get 0.6 simulated seconds; the beat
// itself gets 2.6 to settle its tween, hold, and fx. The schedule
// is fixed, so every run renders the same bytes.
await page.clock.pauseAt(EPOCH + 60_000);
let at = 0;
for (const beat of beats) {
for (; at < beat; at++) {
await page.locator("button", { hasText: "▶" }).last().click();
await page.clock.runFor(at === beat - 1 ? 2600 : 600);
}
const box = await page.locator(".replay").boundingBox();
const name = `${sp.name}-s${beat}-${view}.png`;
const golden = join(GOLDENS, name);
const shot = update ? golden : join(DIFFS, name);
await page.screenshot({
path: shot,
clip: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
});
if (update) {
console.log(`blessed ${name}`);
} else if (!existsSync(golden)) {
console.log(`MISSING golden ${name} — run with --update`);
failures++;
} else {
const { frac, why } = compare(golden, shot);
if (why || frac > FAIL_FRAC) {
console.log(`FAIL ${name}: ${why ?? `${(frac * 100).toFixed(2)}% of pixels drifted`} (actual kept at ${shot})`);
failures++;
} else {
console.log(`OK ${name}`);
}
}
}
await ctx.close();
}
}
await browser.close();
} finally {
server.kill();
rmSync(dataDir, { recursive: true, force: true });
}
if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`);
process.exit(failures ? 1 : 0);