The eyes look down, the die stops the reel, and a punch is a fist
A day of Eric watching the clips as a newcomer would. The camera can pitch: FirstPerson takes a `pitch` that shears the horizon up the pane (walls center on it, the eye stays half a wall high), so the ground at the pane's foot comes within half a cell. The director uses it for deeds at your own feet — a conjuration on your square, a treasure taken up, the pit you fell down — instead of the cutaway that stepped outside your body. Everything shows through a warp. The floor pass now runs between the cast and the wall draw, so a column that bent through a mouth maps its ground through the warp's rigid motion and wears the far room's decals — the pit past the wormhole included — under the same violet haze as its walls. A wizard down a pit is public (inPit on the player view), drawn sunk to the floor line in first person and dimmed on the board. A body sharing your square, which had no depth to draw at, stands right in front of you. A pit leap glides like a shove instead of cutting. And the director's blocked-sight test no longer counts a warp BEYOND the target as a block, nor stands a cutaway camera inside another wizard's square — the pair that put Wanderer's face across the whole pane. The reel stops for a die: a card over the held world names who rolls and for what, tumbles the faces on the reel's own clock, lands the roll, and only then plays the outcome; the beat, the fx, the glides and the camera all wait it out. A punch by you drives a fist into the scene; one at you comes at the camera; both land with a comic POW. film-clips rolls until the reel's last move rather than a fixed count, and the gate settles longer on a step whose caption rolls a die. All thirty goldens re-blessed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
@@ -21,7 +21,6 @@ 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 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();
|
||||
|
||||
@@ -82,15 +81,25 @@ try {
|
||||
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++) {
|
||||
// Roll until the reel reaches its last move (steps a die decides
|
||||
// run longer than the beat), then hold the tail. The poster is
|
||||
// the first pinned beat, settled.
|
||||
const posterBeat = sp.beats?.[0] ?? 1;
|
||||
let f = 0, tail = -1, posterIn = -1, total = 0;
|
||||
const MAX_FRAMES = FPS * 240; // a reel that never ends is a bug, not a feature
|
||||
while (tail !== 0 && f < MAX_FRAMES) {
|
||||
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);
|
||||
const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]);
|
||||
if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(1200 / (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++; total += 1000 / FPS;
|
||||
}
|
||||
const nFrames = f;
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 314 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 73 KiB |
@@ -121,7 +121,10 @@ try {
|
||||
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);
|
||||
// A step the die decides holds the world for the roll's
|
||||
// interlude before anything moves: wait it out on top.
|
||||
const die = (await page.locator(".replay-caption").textContent()).includes("\u{1F3B2}");
|
||||
await page.clock.runFor((at === beat - 1 ? 2600 : 600) + (die ? 2400 : 0));
|
||||
}
|
||||
const box = await page.locator(".replay").boundingBox();
|
||||
const name = `${sp.name}-s${beat}-${view}.png`;
|
||||
|
||||