Clips unfurl: share cards, faststart video, a share button
Each clip page already carried its own title, blurb, poster and og:video — but the assets behind the tags let the unfurl down. The mp4s were tail-indexed, so previewers in iMessage, Discord, Slack and Facebook gave up before finding a player; the tags carried no video dimensions, without which those same sites refuse to embed one; and the poster was a 736x578 screenshot of the workshop chrome, cropped to nothing by any share sheet. deploy/clips-prep.mjs fixes the set before clips-publish.sh ships it: a lossless faststart remux of every take, a 1200x630 card per clip cut from the poster's first-person viewport with the title set over it (an HTML card screenshot through the harness's chromium — ffmpeg here has no drawtext), and the fpv frame size stamped into clips.json. The page emits the card with its dimensions, og:video:width/height, and a Share button that opens the phone's share sheet or copies the link. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
bea8a2e1e1
commit
8488e9a8d1
Executable
+111
@@ -0,0 +1,111 @@
|
||||
// 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, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
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 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 ff = (...args) => execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...args]);
|
||||
|
||||
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 in the gallery's gold over it. */
|
||||
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: #171a21; }
|
||||
.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: #e8c87a; 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: #e8c87a; }
|
||||
</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}`);
|
||||
Regular → Executable
+3
-1
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish showcase clips to the droplet's clip vault (/var/lib/wizwar/clips).
|
||||
# Run deploy/clips-prep.mjs on the directory first: it remuxes the video
|
||||
# for streaming, cuts the share cards, and stamps frame sizes into clips.json.
|
||||
# The gallery at /clips serves whatever this ships: <slug>-fpv.mp4,
|
||||
# <slug>-board.mp4, <slug>.jpg per clip, described by clips.json.
|
||||
# <slug>-board.mp4, <slug>.jpg, <slug>-card.jpg per clip, described by clips.json.
|
||||
set -euo pipefail
|
||||
host=${1:?usage: clips-publish.sh <host> <clips-dir>}
|
||||
dir=${2:?usage: clips-publish.sh <host> <clips-dir>}
|
||||
|
||||
@@ -29,6 +29,25 @@ const PAGE_CSS = `
|
||||
.take h2 { font-size: 1.05rem; color: #e8c87a; font-weight: normal;
|
||||
letter-spacing: 0.04em; margin: 0 0 0.5rem; }
|
||||
.foot { margin-top: 2.2rem; color: #8b8778; font-size: 0.95rem; }
|
||||
.share { font: inherit; font-size: 0.95rem; letter-spacing: 0.03em; cursor: pointer;
|
||||
color: #e8c87a; background: #1f232d; border: 1px solid #3a4052; border-radius: 6px;
|
||||
padding: 0.45rem 1rem; margin: -0.6rem 0 0.4rem; }
|
||||
.share:hover { border-color: #e8c87a; }
|
||||
.share.done { color: #b5b0a1; }
|
||||
`;
|
||||
|
||||
// The share button: the phone's own share sheet where there is one,
|
||||
// the clipboard everywhere else. The page URL is the canonical link.
|
||||
const SHARE_JS = `
|
||||
var b = document.querySelector(".share");
|
||||
b.addEventListener("click", function () {
|
||||
var data = { title: b.dataset.title, text: b.dataset.text, url: location.origin + location.pathname };
|
||||
if (navigator.share) { navigator.share(data).catch(function () {}); return; }
|
||||
navigator.clipboard.writeText(data.url).then(function () {
|
||||
b.textContent = "Link copied"; b.classList.add("done");
|
||||
setTimeout(function () { b.textContent = "Share this clip"; b.classList.remove("done"); }, 1800);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
const shell = (metas: string[], body: string) => `<!doctype html>
|
||||
@@ -42,6 +61,22 @@ ${metas.join("\n")}
|
||||
<body><div class="wrap">${body}</div></body>
|
||||
</html>`;
|
||||
|
||||
/** The 1200x630 share card cut by deploy/clips-prep.mjs, with the
|
||||
* dimensions that let Facebook show it on the very first share. Falls
|
||||
* back to the poster on a set that was never prepped. */
|
||||
function cardImageMetas(clip: ClipMeta, base: string): string[] {
|
||||
const prepped = clip.width !== undefined;
|
||||
const image = `${base}/clips/${clip.name}${prepped ? "-card" : ""}.jpg`;
|
||||
return [
|
||||
`<meta property="og:image" content="${image}"/>`,
|
||||
...(prepped ? [
|
||||
`<meta property="og:image:width" content="1200"/>`,
|
||||
`<meta property="og:image:height" content="630"/>`,
|
||||
] : []),
|
||||
`<meta name="twitter:image" content="${image}"/>`,
|
||||
];
|
||||
}
|
||||
|
||||
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
||||
const metas = [
|
||||
`<title>Wiz-War — clips from the labyrinth</title>`,
|
||||
@@ -50,9 +85,9 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
||||
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
|
||||
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
|
||||
`<meta property="og:url" content="${base}/clips"/>`,
|
||||
clips[0] ? `<meta property="og:image" content="${base}/clips/${clips[0].name}.jpg"/>` : "",
|
||||
...(clips[0] ? cardImageMetas(clips[0], base) : []),
|
||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||
].filter(Boolean);
|
||||
];
|
||||
const cards = clips.map((c) => `
|
||||
<a class="card" href="/clips/${c.name}">
|
||||
<img src="/clips/${c.name}.jpg" alt="${esc(c.title)}" loading="lazy">
|
||||
@@ -80,16 +115,20 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
|
||||
`<meta property="og:title" content="${esc(clip.title)}"/>`,
|
||||
`<meta property="og:description" content="${esc(clip.blurb)}"/>`,
|
||||
`<meta property="og:url" content="${base}/clips/${clip.name}"/>`,
|
||||
`<meta property="og:image" content="${base}/clips/${clip.name}.jpg"/>`,
|
||||
...cardImageMetas(clip, base),
|
||||
`<meta property="og:video" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
||||
`<meta property="og:video:secure_url" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
|
||||
`<meta property="og:video:type" content="video/mp4"/>`,
|
||||
...(clip.width && clip.height ? [
|
||||
`<meta property="og:video:width" content="${clip.width}"/>`,
|
||||
`<meta property="og:video:height" content="${clip.height}"/>`,
|
||||
] : []),
|
||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||
`<meta name="twitter:image" content="${base}/clips/${clip.name}.jpg"/>`,
|
||||
];
|
||||
const body = `
|
||||
<h1><a href="/clips">Clips from the labyrinth</a></h1>
|
||||
<p class="sub">${esc(clip.title)} — ${esc(clip.blurb)}</p>
|
||||
<button class="share" type="button" data-title="${esc(clip.title)}" data-text="${esc(clip.blurb)}">Share this clip</button>
|
||||
<div class="takes">
|
||||
<div class="take">
|
||||
<h2>Through the wizard's eyes</h2>
|
||||
@@ -101,6 +140,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<p class="foot">Played by the real rules engine — every move on screen is a legal move.
|
||||
<a href="/">Deal yourself in</a>.</p>`;
|
||||
<a href="/">Deal yourself in</a>.</p>
|
||||
<script>${SHARE_JS}</script>`;
|
||||
return shell(metas, body);
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ export interface ClipMeta {
|
||||
title: string;
|
||||
blurb: string;
|
||||
seconds: number;
|
||||
/** Frame size of the first-person take, stamped by deploy/clips-prep.mjs;
|
||||
* unfurlers won't embed a player without it. Absent on an unprepped set. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const clipsDir = () => join(DATA_DIR, "..", "clips");
|
||||
@@ -244,9 +248,9 @@ export function readClips(): ClipMeta[] {
|
||||
|
||||
/** Resolve a clip asset request to its path — or null for any name that
|
||||
* is not exactly a published clip file shape. The gate IS the security:
|
||||
* nothing outside <slug>(-fpv|-board).mp4 / <slug>.jpg can be named. */
|
||||
* nothing outside <slug>(-fpv|-board).mp4 / <slug>(-card)?.jpg can be named. */
|
||||
export function clipAssetPath(file: string): string | null {
|
||||
if (!/^[a-z0-9-]{1,60}(-fpv|-board)\.mp4$/.test(file) && !/^[a-z0-9-]{1,60}\.jpg$/.test(file)) {
|
||||
if (!/^[a-z0-9-]{1,60}(-fpv|-board)\.mp4$/.test(file) && !/^[a-z0-9-]{1,60}(-card)?\.jpg$/.test(file)) {
|
||||
return null;
|
||||
}
|
||||
const path = join(clipsDir(), file);
|
||||
|
||||
Reference in New Issue
Block a user