diff --git a/deploy/clips-prep.mjs b/deploy/clips-prep.mjs new file mode 100755 index 0000000..e8d3a60 --- /dev/null +++ b/deploy/clips-prep.mjs @@ -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 +// +// 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 (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(/
+ +
+
${esc(title)}
+
WIZ-WAR  ·  a clip from the labyrinth
+
`; +} + +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}`); diff --git a/deploy/clips-publish.sh b/deploy/clips-publish.sh old mode 100644 new mode 100755 index c0f1ce4..b089d5f --- a/deploy/clips-publish.sh +++ b/deploy/clips-publish.sh @@ -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: -fpv.mp4, -# -board.mp4, .jpg per clip, described by clips.json. +# -board.mp4, .jpg, -card.jpg per clip, described by clips.json. set -euo pipefail host=${1:?usage: clips-publish.sh } dir=${2:?usage: clips-publish.sh } diff --git a/packages/server/src/clips.ts b/packages/server/src/clips.ts index 4357637..ba9675d 100644 --- a/packages/server/src/clips.ts +++ b/packages/server/src/clips.ts @@ -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) => ` @@ -42,6 +61,22 @@ ${metas.join("\n")}
${body}
`; +/** 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 [ + ``, + ...(prepped ? [ + ``, + ``, + ] : []), + ``, + ]; +} + export function clipsIndexHtml(clips: ClipMeta[], base: string): string { const metas = [ `Wiz-War — clips from the labyrinth`, @@ -50,9 +85,9 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string { ``, ``, ``, - clips[0] ? `` : "", + ...(clips[0] ? cardImageMetas(clips[0], base) : []), ``, - ].filter(Boolean); + ]; const cards = clips.map((c) => ` ${esc(c.title)} @@ -80,16 +115,20 @@ export function clipPageHtml(clip: ClipMeta, base: string): string { ``, ``, ``, - ``, + ...cardImageMetas(clip, base), ``, ``, ``, + ...(clip.width && clip.height ? [ + ``, + ``, + ] : []), ``, - ``, ]; const body = `

Clips from the labyrinth

${esc(clip.title)} — ${esc(clip.blurb)}

+

Through the wizard's eyes

@@ -101,6 +140,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {

Played by the real rules engine — every move on screen is a legal move. - Deal yourself in.

`; + Deal yourself in.

+ `; return shell(metas, body); } diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index fc2cbe4..5011007 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -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 (-fpv|-board).mp4 / .jpg can be named. */ + * nothing outside (-fpv|-board).mp4 / (-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);