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:
Eric Wagoner
2026-09-02 11:26:27 -04:00
co-authored by Claude Fable 5.1
parent bea8a2e1e1
commit 8488e9a8d1
4 changed files with 165 additions and 8 deletions
+45 -5
View File
@@ -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);
}
+6 -2
View File
@@ -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);