Files
wizwar6e/deploy/film-clips.mjs
T
Eric WagonerandClaude Fable 5.1 e1a740119c Credibility pass: the session seams sanded from the clips-and-camera batch
Two blind reviews of everything since the last pass (afa0e17), every
finding checked against the code, no behavior changed: all thirty
scene goldens match without a re-bless and the engine suite is
untouched.

Reel and renderer: the camera's look-down rule is stated once, beside
LOOK_DOWN, instead of twice in the effect; the empty aim branch that
stood where a cutaway used to be is gone (the guard it implied is now
explicit); the pit events and the punch are handled by their own
types, not through "in" casts; smoothstep is one export used by every
tween instead of eleven inline copies; the two floor rings share one
painter; project() takes a Billboard instead of a third hand-typed
copy of its fields; the strides-left figure and the web rim no longer
shadow the reel's steps and the pane's fx; the die card's verdict is
built from events, not by matching an emoji; the workshop asks for the
hover cue by name instead of passing an empty click handler.

Server and engine: one requestBase() for the origin, one slug pattern
in store.ts gating both the clip page and its files, one 404 for both;
LOOPBACK sits above its only caller; doCounteract names what a counter
is played against once; fearCells sits beside its own docblock rather
than between sightedCellsFor and its.

Deploy: chromiumExe, the private server, ffmpeg, and the reel rewind
live in deploy/lib/harness.mjs, shared by the gate, the recorder, and
the card cutter instead of pasted three times; the recorder drops its
duplicate frame counters and names its poster settle; the card uses the
gallery's exact gold; the one-time Sentry URL bootstrap leaves
deploy.sh; the backup comment states the rule rather than the incident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 11:55:57 -04:00

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 = 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();
}