diff --git a/deploy/film-clips.mjs b/deploy/film-clips.mjs new file mode 100644 index 0000000..926421f --- /dev/null +++ b/deploy/film-clips.mjs @@ -0,0 +1,120 @@ +// 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 { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, 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 [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 STEP_MS = 1500; // the reel's own pace at 1x +const TAIL_MS = 2500; // hold on the last move +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)"); +} + +const dataDir = mkdtempSync(join(tmpdir(), "wizwar-film-")); +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 { if ((await fetch(`http://127.0.0.1:${PORT}/`)).ok) return; } catch { /* not yet */ } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error("server never came up"); +}; + +try { + await 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" }); + await page.locator("button", { hasText: "❚❚" }).click(); + const count = Number((await page.locator(".replay-count").textContent()).match(/of (\d+)/)[1]); + for (let i = 0; i < count; i++) await page.locator("button", { hasText: "◀" }).click(); + 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(); + const total = (count - 1) * STEP_MS + TAIL_MS; + const nFrames = Math.ceil(total / (1000 / FPS)); + const posterAt = ((sp.beats?.[0] ?? 1) * STEP_MS + 1200) / (1000 / FPS); + for (let f = 0; f < nFrames; f++) { + const png = await page.screenshot({ clip }); + writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png); + if (view === "fpv" && f === Math.round(posterAt)) writeFileSync(join(frames, "poster.png"), png); + await page.clock.runFor(1000 / FPS); + } + await ctx.close(); + execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-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"); + execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`)]); + seconds = Math.round(total / 1000); + } + rmSync(frames, { recursive: true, force: true }); + console.log(`${sp.name} ${view}: ${nFrames} 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.kill(); + rmSync(dataDir, { recursive: true, force: true }); +} diff --git a/deploy/scene-goldens/wormhole-heist-s10-fpv.png b/deploy/scene-goldens/wormhole-heist-s10-fpv.png index 505e417..ffa6153 100644 Binary files a/deploy/scene-goldens/wormhole-heist-s10-fpv.png and b/deploy/scene-goldens/wormhole-heist-s10-fpv.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s5-fpv.png b/deploy/scene-goldens/wormhole-heist-s5-fpv.png index a2a9640..0f924f2 100644 Binary files a/deploy/scene-goldens/wormhole-heist-s5-fpv.png and b/deploy/scene-goldens/wormhole-heist-s5-fpv.png differ diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 5fb2eb5..3e87511 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -125,20 +125,10 @@ export function fpFxForEvents( }); } } else if (fx.kind === "portal") { - // The board ripples the mouth; here the mouth's own veil surges. - // Every mouth heads one entry of board.warps, the index the - // raycaster tags its columns with. - const warp = view.board.warps.findIndex( - (w) => w.from.cell.x === fx.cell.x && w.from.cell.y === fx.cell.y && w.from.side === fx.side, - ); - if (warp >= 0) { - out.push({ fx: { id: nextId++, kind: "surge", warp, t0: 0, dur: 900 }, delay }); - } else { - out.push({ - fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: fx.cell.x + 0.5, y: fx.cell.y + 0.5 }, t0: 0, dur: 550 }, - delay, - }); - } + // The board ripples a mouth on every crossing. In here a warp is + // a doorway like any other — the veil already marks it, and a + // body walks through as it would a door. A mouth just OPENED is + // staged from the raw event below. } else if ("at" in fx && IMPACT_ART[fx.kind]) { out.push({ fx: { id: nextId++, kind: "impact", art: IMPACT_ART[fx.kind]!, at: { x: fx.at.x + 0.5, y: fx.at.y + 0.5 }, t0: 0, dur: 550 }, @@ -147,8 +137,17 @@ export function fpFxForEvents( } } // The maze itself performs: doors swing for whoever steps through, - // walls die into rubble, and walking THROUGH stone shimmers. + // walls die into rubble, walking THROUGH stone shimmers, and a + // wormhole just cast surges through both its new mouths. + const warpIndex = (m: { cell: { x: number; y: number }; side: string }) => + view.board.warps.findIndex((w) => w.from.cell.x === m.cell.x && w.from.cell.y === m.cell.y && w.from.side === m.side); for (const e of events) { + if (e.type === "warpOpened") { + for (const [m, delay] of [[e.a, 0], [e.b, 200]] as const) { + const warp = warpIndex(m); + if (warp >= 0) out.push({ fx: { id: nextId++, kind: "surge", warp, t0: 0, dur: 900 }, delay }); + } + } if ((e.type === "moved" || e.type === "creatureMoved") && "direction" in e) { const via = e.type === "moved" ? e.via : "step"; const key = edgeKey(e.from, e.direction);