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
98 lines
4.8 KiB
JavaScript
Executable File
98 lines
4.8 KiB
JavaScript
Executable File
// Make a clips directory unfurl well before clips-publish.sh ships it:
|
|
// - remux every mp4 with its index up front (faststart). Previewers in
|
|
// iMessage, Discord, Slack and Facebook read the head of the file to
|
|
// decide whether to show a player, and give up on a tail-indexed one.
|
|
// - cut a 1200x630 share card per clip — the poster's first-person
|
|
// viewport, full-bleed, with the title set over it — for og:image.
|
|
// Share sheets crop anything that isn't 1.91:1; X crops to 2:1.
|
|
// - stamp each clip's video width/height into clips.json so the page
|
|
// can emit og:video:width/height, without which Facebook, LinkedIn
|
|
// and Discord refuse to embed a player.
|
|
//
|
|
// npx tsx deploy/clips-prep.mjs <clips-dir>
|
|
//
|
|
// Needs ffmpeg + ffprobe and the Playwright chromium cache (see
|
|
// verify-scenes.mjs). Idempotent: run it again after re-recording.
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { chromium } from "playwright-core";
|
|
import { chromiumExe, ff } from "./lib/harness.mjs";
|
|
|
|
const dir = process.argv[2];
|
|
if (!dir || !existsSync(join(dir, "clips.json"))) {
|
|
console.error("usage: clips-prep.mjs <clips-dir> (a directory holding clips.json)");
|
|
process.exit(1);
|
|
}
|
|
|
|
// The workshop poster is a screenshot of the replay stage; the wizard's
|
|
// viewport sits at this rectangle within it — a 16:9 window.
|
|
const VIEWPORT = { x: 18, y: 51, w: 700, h: 393 };
|
|
const CARD = { width: 1200, height: 630 };
|
|
|
|
function faststart(file) {
|
|
const tmp = `${file}.tmp.mp4`;
|
|
ff("-i", file, "-c", "copy", "-movflags", "+faststart", tmp);
|
|
renameSync(tmp, file);
|
|
}
|
|
|
|
function videoSize(file) {
|
|
const [width, height] = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0",
|
|
"-show_entries", "stream=width,height", "-of", "csv=p=0", file]).toString().trim().split(",").map(Number);
|
|
return { width, height };
|
|
}
|
|
|
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<");
|
|
|
|
/** The share card: the viewport scaled to the card's width (a 16:9 crop
|
|
* runs 674 tall at 1200 wide, so 22px shave top and bottom), a shade
|
|
* rising from the bottom, the title over it in the gallery's gold — set
|
|
* in Georgia, since the card is rendered offline with no web fonts. */
|
|
function cardHtml(title, viewportPngDataUrl) {
|
|
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
html, body { margin: 0; width: ${CARD.width}px; height: ${CARD.height}px; overflow: hidden; background: #171a20; }
|
|
.card { position: relative; width: ${CARD.width}px; height: ${CARD.height}px;
|
|
font-family: Georgia, "Times New Roman", serif; color: #d6d2c4; }
|
|
.card img { position: absolute; left: 0; top: -22px; width: ${CARD.width}px; height: 674px;
|
|
image-rendering: auto; }
|
|
.shade { position: absolute; inset: 0;
|
|
background: linear-gradient(to bottom, rgba(23,26,33,0) 45%, rgba(23,26,33,0.92) 100%); }
|
|
.title { position: absolute; left: 56px; right: 56px; bottom: 94px;
|
|
font-size: 66px; line-height: 1.1; color: #e0b34a; letter-spacing: 0.02em;
|
|
text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
|
|
.mark { position: absolute; left: 58px; bottom: 52px; font-size: 26px; letter-spacing: 0.08em;
|
|
color: #d6d2c4; opacity: 0.9; text-shadow: 0 1px 8px rgba(0,0,0,0.6); }
|
|
.mark b { font-weight: normal; color: #e0b34a; }
|
|
</style></head><body><div class="card">
|
|
<img src="${viewportPngDataUrl}">
|
|
<div class="shade"></div>
|
|
<div class="title">${esc(title)}</div>
|
|
<div class="mark"><b>WIZ-WAR</b> · a clip from the labyrinth</div>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
const clipsFile = join(dir, "clips.json");
|
|
const clips = JSON.parse(readFileSync(clipsFile, "utf8"));
|
|
const browser = await chromium.launch({ executablePath: chromiumExe() });
|
|
const page = await browser.newPage({ viewport: CARD, deviceScaleFactor: 1 });
|
|
try {
|
|
for (const clip of clips) {
|
|
for (const take of ["fpv", "board"]) faststart(join(dir, `${clip.name}-${take}.mp4`));
|
|
Object.assign(clip, videoSize(join(dir, `${clip.name}-fpv.mp4`)));
|
|
|
|
const crop = join(dir, `.${clip.name}-viewport.png`);
|
|
ff("-i", join(dir, `${clip.name}.jpg`),
|
|
"-vf", `crop=${VIEWPORT.w}:${VIEWPORT.h}:${VIEWPORT.x}:${VIEWPORT.y}`, crop);
|
|
const dataUrl = `data:image/png;base64,${readFileSync(crop).toString("base64")}`;
|
|
unlinkSync(crop);
|
|
await page.setContent(cardHtml(clip.title, dataUrl));
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await page.screenshot({ path: join(dir, `${clip.name}-card.jpg`), type: "jpeg", quality: 88 });
|
|
console.log(`${clip.name}: ${clip.width}x${clip.height}, faststart, card`);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
writeFileSync(clipsFile, JSON.stringify(clips, null, 2) + "\n");
|
|
console.log(`prepped ${clips.length} clips in ${dir}`);
|