// 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 -fpv.mp4, -board.mp4, // .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 [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 [scene ...]"); process.exit(1); } const PORT = 8809; const FPS = 25; const TAIL_MS = 2500; // hold on the last move const POSTER_SETTLE_MS = 1200; // the poster beat's tween and fx, 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(); }