For visitors: the landing shows a clip card of the maze before asking for a name; "Play now against a clockwork wizard" is one tap from the landing to the first turn (the room, the seat, and the flip each follow as the server confirms the last); pass-the-device and seat transfer fold behind "More ways to play". For a first game: automatons default to apprentice until this browser has seen a game through, then step up to adept unless the tier was picked by hand; the council speaks once unasked on a first turn; a finished game offers "play again" (the same bots re-seated in a fresh room) and "share the tale" (the whole replay as a link). For players who know the box: a House rulings tab in help — what the digital table does differently, every rulings revision (now exported from the engine as RULES_REVISIONS, which the deckRev doc block points at), and the 76 cards the FAQ has spoken on. And the arrow keys and WASD walk on the board as they already did in the first-person pane. The recorder's poster settle lengthens to outlast a change-of-eyes wipe; two cards had caught the title card instead of the maze. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
100 lines
5.1 KiB
JavaScript
100 lines
5.1 KiB
JavaScript
// Film the screenplay catalog for the public gallery: every scene in two
|
|
// takes — through the pov wizard's eyes and from the board — as the reel
|
|
// plays it under a paused Playwright clock, one screenshot per 40ms of
|
|
// simulated time, muxed by ffmpeg into 25fps mp4s. The poster is the
|
|
// first pinned beat, settled. Writes <slug>-fpv.mp4, <slug>-board.mp4,
|
|
// <slug>.jpg and clips.json into the output dir — then run
|
|
// clips-prep.mjs and clips-publish.sh on it.
|
|
//
|
|
// npx tsx deploy/film-clips.mjs <out-dir> [scene ...]
|
|
//
|
|
// Run from the repo root AFTER `npx vite build` in packages/web. Needs
|
|
// ffmpeg and the Playwright chromium cache (see verify-scenes.mjs).
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { chromium } from "playwright-core";
|
|
import { chromiumExe, ff, rewindReel, startServer } from "./lib/harness.mjs";
|
|
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
|
|
|
|
const [outDir, ...only] = process.argv.slice(2);
|
|
if (!outDir) { console.error("usage: film-clips.mjs <out-dir> [scene ...]"); process.exit(1); }
|
|
const PORT = 8809;
|
|
const FPS = 25;
|
|
const TAIL_MS = 2500; // hold on the last move
|
|
const POSTER_SETTLE_MS = 2000; // the poster beat's tween, fx, and any change-of-eyes wipe, done
|
|
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
|
|
|
|
const server = startServer(PORT, "wizwar-film");
|
|
|
|
try {
|
|
await server.up();
|
|
mkdirSync(outDir, { 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(", ")}`);
|
|
const catalog = [];
|
|
|
|
for (const sp of scenes) {
|
|
let seconds = 0;
|
|
for (const view of ["fpv", "board"]) {
|
|
const frames = mkdtempSync(join(tmpdir(), `wizwar-frames-${sp.name}-${view}-`));
|
|
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" });
|
|
const count = await rewindReel(page);
|
|
if (view === "fpv") await page.getByText("your eyes").click();
|
|
await page.clock.pauseAt(EPOCH + 60_000);
|
|
await page.clock.runFor(400); // let the first frame settle before rolling
|
|
const box = await page.locator(".replay").boundingBox();
|
|
const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) };
|
|
// Roll: the reel's play button is the ▶ before the step button.
|
|
await page.locator("button", { hasText: "▶" }).first().click();
|
|
// Roll until the reel reaches its last move (steps a die decides
|
|
// run longer than the beat), then hold the tail.
|
|
const posterBeat = sp.beats?.[0] ?? 1;
|
|
const MAX_FRAMES = FPS * 240; // four minutes: a reel that never reaches its last move
|
|
let f = 0, tail = -1, posterIn = -1;
|
|
while (tail !== 0 && f < MAX_FRAMES) {
|
|
const png = await page.screenshot({ clip });
|
|
writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png);
|
|
const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]);
|
|
if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(POSTER_SETTLE_MS / (1000 / FPS));
|
|
if (posterIn === 0) writeFileSync(join(frames, "poster.png"), png);
|
|
if (posterIn >= 0) posterIn--;
|
|
if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS));
|
|
if (tail > 0) tail--;
|
|
await page.clock.runFor(1000 / FPS);
|
|
f++;
|
|
}
|
|
await ctx.close();
|
|
ff("-framerate", String(FPS), "-i", join(frames, "%05d.png"),
|
|
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "22",
|
|
"-movflags", "+faststart", join(outDir, `${sp.name}-${view}.mp4`));
|
|
if (view === "fpv") {
|
|
const poster = existsSync(join(frames, "poster.png")) ? join(frames, "poster.png") : join(frames, "00000.png");
|
|
ff("-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`));
|
|
seconds = Math.round(f / FPS);
|
|
}
|
|
rmSync(frames, { recursive: true, force: true });
|
|
console.log(`${sp.name} ${view}: ${f} frames`);
|
|
}
|
|
catalog.push({ name: sp.name, title: sp.title, blurb: sp.blurb, seconds });
|
|
}
|
|
await browser.close();
|
|
// A partial run keeps the rest of an existing catalog.
|
|
const file = join(outDir, "clips.json");
|
|
const prior = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : [];
|
|
const merged = SCREENPLAYS.map((sp) =>
|
|
catalog.find((c) => c.name === sp.name) ?? prior.find((c) => c.name === sp.name)).filter(Boolean);
|
|
writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
|
|
console.log(`filmed ${catalog.length} scenes into ${outDir}`);
|
|
} finally {
|
|
server.stop();
|
|
}
|