From 759f48f5be5552f7b8c50af2887e7bd352bcfbd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 15:50:22 +0000 Subject: [PATCH] feat(video): add release/feature video pipeline and launch-video skill Add a maintainer-only pipeline that records sideshow product videos in one real-time pass: scripts/launch-video/stage.html frames the live viewer in a 1920x1080 stage (window chrome, caption lower-third, title cards) and record.mjs boots a fresh server, seeds demo content, and drives the storyboard with Playwright while recording video, ready for ffmpeg encoding to mp4/webm. Unlike hunk's keyframe compositor, sideshow's product is live motion (cards streaming in, the comment loop), so a single recorded pass is the right capture model. skills/launch-video/SKILL.md documents the recipes (full release, single feature/PR, custom), the storyboard vocabulary, and the environment gotchas learned while producing the 0.13.0 release video with it (CSP frame-ancestors stripping, overlay pointer-events, iframe settle time, ffmpeg/x264, fonts). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WHgzU15du8dEN3BJiWD3kx --- .changeset/brave-pianos-film.md | 2 + .gitignore | 1 + scripts/launch-video/record.mjs | 236 ++++++++++++++++++++++++++++ scripts/launch-video/stage.html | 266 ++++++++++++++++++++++++++++++++ skills/launch-video/SKILL.md | 164 ++++++++++++++++++++ 5 files changed, 669 insertions(+) create mode 100644 .changeset/brave-pianos-film.md create mode 100644 scripts/launch-video/record.mjs create mode 100644 scripts/launch-video/stage.html create mode 100644 skills/launch-video/SKILL.md diff --git a/.changeset/brave-pianos-film.md b/.changeset/brave-pianos-film.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/brave-pianos-film.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.gitignore b/.gitignore index 4240f095..baa81d72 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ viewer/dist-embed/ test-results/ playwright-report/ coverage/ +.video-work/ diff --git a/scripts/launch-video/record.mjs b/scripts/launch-video/record.mjs new file mode 100644 index 00000000..52447572 --- /dev/null +++ b/scripts/launch-video/record.mjs @@ -0,0 +1,236 @@ +// Records a sideshow release/feature video in one real-time pass: boots a +// fresh server, loads the live viewer inside the 1920x1080 stage +// (stage.html: window chrome + captions + title cards), drives the storyboard +// below with Playwright while recording video, and prints the raw webm path. +// +// node scripts/launch-video/record.mjs +// # then encode (see skills/launch-video/SKILL.md): +// ffmpeg -y -i .video-work/raw.webm -vf "fps=30,format=yuv420p" \ +// -c:v libx264 -preset slow -crf 18 -movflags +faststart .video-work/release.mp4 +// +// The storyboard (SCENES below + the cards/captions) is editorial content for +// one video — rewrite it per release. The stage + boot/seed/record machinery +// is reusable. + +import { chromium } from "@playwright/test"; +import { execSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { DEMO_SESSIONS } from "../../bin/demoData.js"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const WORK = process.argv[2] ?? join(ROOT, ".video-work"); +mkdirSync(WORK, { recursive: true }); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --- viewer build + server boot --------------------------------------------- + +if (!existsSync(join(ROOT, "viewer", "dist", "index.html"))) { + execSync("npm run build:viewer", { cwd: ROOT, stdio: "inherit" }); +} + +const proc = spawn(process.execPath, [join(ROOT, "server", "index.ts")], { + env: { ...process.env, PORT: "0", SIDESHOW_DB: join(WORK, `rec-${Date.now()}.db`) }, + stdio: ["ignore", "pipe", "inherit"], +}); +const base = await new Promise((resolve, reject) => { + let out = ""; + proc.stdout.on("data", (chunk) => { + out += chunk; + const m = out.match(/listening on (http:\/\/localhost:\d+)/); + if (m) resolve(m[1]); + }); + setTimeout(() => reject(new Error("server did not boot")), 15_000); +}); + +const api = (path, body, init = {}) => + fetch(`${base}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + ...init, + }).then(async (r) => { + const json = await r.json(); + if (!r.ok) throw new Error(`${path}: ${JSON.stringify(json)}`); + return json; + }); + +// --- seed -------------------------------------------------------------------- + +// Background session (gives the sidebar a second entry to switch to). +const queueDemo = DEMO_SESSIONS.find((d) => d.title === "Queue profiling"); +const queueSession = await api("/api/sessions", { agent: queueDemo.agent, title: queueDemo.title }); +for (const snip of queueDemo.snippets) { + await api("/api/posts", { + session: queueSession.id, + title: snip.title, + surfaces: [{ kind: "html", html: snip.html }], + }); +} + +// Foreground session — starts empty; posts stream in on camera. +const authDemo = DEMO_SESSIONS.find((d) => d.title === "Auth refactor"); +const [jwt, backoff] = authDemo.snippets; +const [userComment, v2, agentReply] = jwt.followups; +const session = await api("/api/sessions", { agent: authDemo.agent, title: authDemo.title }); + +// --- stage + browser --------------------------------------------------------- + +// Optional caption fonts (npm i @fontsource-variable/inter @fontsource/jetbrains-mono +// in the work dir); the stage falls back to system fonts when absent. +const font = (rel) => { + const p = join(WORK, "node_modules", rel); + return existsSync(p) ? pathToFileURL(p).href : "about:blank"; +}; +const stageHtml = readFileSync(join(ROOT, "scripts", "launch-video", "stage.html"), "utf8") + .replaceAll("__INTER__", font("@fontsource-variable/inter/files/inter-latin-wght-normal.woff2")) + .replaceAll( + "__MONO__", + font("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2"), + ) + .replaceAll("__APP_URL__", base) + .replaceAll("__APP_HOST__", base.replace(/^https?:\/\//, "")); +const stagePath = join(WORK, "stage.resolved.html"); +writeFileSync(stagePath, stageHtml); + +const executablePath = + process.env.CHROMIUM_PATH ?? + (existsSync("/opt/pw-browsers/chromium") ? "/opt/pw-browsers/chromium" : undefined); +const browser = await chromium.launch({ + executablePath, + args: ["--allow-file-access-from-files"], +}); +const size = { width: 1920, height: 1080 }; +const context = await browser.newContext({ + viewport: size, + recordVideo: { dir: WORK, size }, + colorScheme: "dark", +}); +// The viewer document sends `frame-ancestors 'self'` (clickjacking hardening), +// which would refuse the file:// stage's iframe — strip CSP on that one +// response for the recording. Never intercept /api/events (SSE would buffer). +await context.route(`${base}/`, async (route) => { + const response = await route.fetch(); + const headers = { ...response.headers() }; + delete headers["content-security-policy"]; + await route.fulfill({ response, headers }); +}); + +const page = await context.newPage(); +await page.goto(pathToFileURL(stagePath).href); +const app = page.frameLocator("#app"); +const stage = (fn, arg) => page.evaluate(([f, a]) => window.stage[f](a), [fn, arg]); + +// --- storyboard -------------------------------------------------------------- + +// 1. Intro card (covers the viewer while it boots + selects the session). +await stage( + "card", + ` +
RELEASE
+

sideshow 0.13.0

+

a live visual surface for your coding agents

`, +); +await app.locator("aside .sess").first().waitFor(); +await app.locator("aside .sess-title", { hasText: authDemo.title }).click(); +await sleep(3200); + +// 2. Publish → cards stream in live. +await stage("hideCard"); +await sleep(700); +await stage( + "caption", + `Agents publish over CLI, MCP, or plain HTTP — cards render live in your browser`, +); +await sleep(900); +const post = await api("/api/posts", { + session: session.id, + title: jwt.title, + surfaces: [{ kind: "html", html: jwt.html }], +}); +await app.locator(".card:not(#whatsNew) iframe").first().waitFor(); +await sleep(2600); +await api("/api/posts", { + session: session.id, + title: backoff.title, + surfaces: [{ kind: "html", html: backoff.html }], +}); +await sleep(2600); + +// 3. The feedback loop: user comments, agent revises + replies. +await stage( + "caption", + `Comment on a card — your agent gets it, revises, and replies`, +); +await sleep(800); +const firstCard = app.locator(".card:not(#whatsNew)", { hasText: jwt.title }); +await firstCard.scrollIntoViewIfNeeded(); +const input = firstCard.locator(".composer input"); +if (!(await input.isVisible().catch(() => false))) { + // The composer is folded behind the card-footer comment icon button. + await firstCard.locator("button.act.comment").click(); +} +await input.click(); +await input.pressSequentially(userComment.comment.text, { delay: 34 }); +await sleep(400); +await input.press("Enter"); +await sleep(1300); +await api( + `/api/posts/${post.id}`, + { surfaces: [{ kind: "html", html: v2.update.html }] }, + { method: "PUT" }, +); +await sleep(1500); +await api("/api/comments", { surface: post.id, ...agentReply.comment }); +await sleep(2400); + +// 4. NEW in 0.13.0 — sidebar rail. +await stage( + "caption", + `NEW Collapse the sidebar into a narrow rail — more room for the work`, +); +await sleep(900); +await app.locator(".sidebar-toggle").click(); +await sleep(2200); +await app.locator(".sidebar-toggle").click(); +await sleep(1200); + +// 5. Perf: switching sessions is light now. +await stage( + "caption", + `Sessions hydrate up to 95% lighter, on indexed SQLite hot paths`, +); +await sleep(700); +await app.locator("aside .sess-title", { hasText: queueDemo.title }).click(); +await page.mouse.move(960, 720); // park the pointer so no sidebar hover state shows +await sleep(2600); +await app.locator("aside .sess-title", { hasText: authDemo.title }).click(); +await page.mouse.move(960, 720); +// Hold until the sandboxed surface iframes have re-rendered, so the last +// live shot before the outro shows real content, not still-loading frames. +await app.locator(".card:not(#whatsNew) iframe").first().waitFor(); +await sleep(3000); + +// 6. Outro card. +await stage("caption", ``); +await stage( + "card", + ` +

sideshow 0.13.0

+
+
$ npm i -g sideshow
+
$ sideshow serve --open
+
+

github.com/modem-dev/sideshow

`, +); +await sleep(4200); + +// --- finish ------------------------------------------------------------------ + +const video = page.video(); +await context.close(); +await browser.close(); +proc.kill(); +const raw = await video.path(); +console.log(raw); diff --git a/scripts/launch-video/stage.html b/scripts/launch-video/stage.html new file mode 100644 index 00000000..3ae47f7e --- /dev/null +++ b/scripts/launch-video/stage.html @@ -0,0 +1,266 @@ + + + + + + + + +
+
+
+ + __APP_HOST__ +
+ +
+
+
+
+ + + diff --git a/skills/launch-video/SKILL.md b/skills/launch-video/SKILL.md new file mode 100644 index 00000000..d2ff8589 --- /dev/null +++ b/skills/launch-video/SKILL.md @@ -0,0 +1,164 @@ +--- +name: launch-video +description: Produces sideshow release/feature videos by driving the real viewer in a recording Chromium against a live server — 1920x1080 stage with window chrome, captions, and title cards, encoded with ffmpeg. Use for release roundups, single-feature demos, PR walkthroughs, and social announcements. +--- + +# Sideshow video pipeline + +Maintainer-only: requires a sideshow source checkout (the pipeline lives in +`scripts/launch-video/`, which never ships to npm). + +Generates product videos where every app frame is the real sideshow viewer +talking to a real server — no screen recording, no mockups. Unlike hunk's +keyframe-compositing pipeline (`skills/launch-video` in the hunk repo), +sideshow's product IS live motion — cards streaming in over SSE, sandboxed +iframes resizing, the comment loop — so this pipeline records **one real-time +pass** instead of compositing stills: + +```text +stage.html a 1920x1080 HTML stage: window chrome + caption lower-third + + full-screen title cards, with the live viewer in an iframe +record.mjs boots a fresh server, seeds demo content, drives the storyboard + with Playwright while recording video, prints the raw webm path +ffmpeg re-encodes the raw webm to mp4 (h264) and webm (vp9) +``` + +Two halves live in `record.mjs`: reusable machinery (boot, seed, CSP strip, +stage resolution, browser/recording setup) and the **storyboard** — the scene +sequence, captions, and title cards. The storyboard is editorial content for +one video; rewrite it per video. As of this writing the checked-in reference +storyboard is the 0.13.0 release video. + +## Creating a video + +Expect a run to take roughly the video's length plus ~20s boot; encoding adds +a couple of minutes. Total output should stay near 40–60s. + +```sh +# 0. one-time work-dir setup (fonts are optional but much nicer than DejaVu) +mkdir -p .video-work && cd .video-work +printf '{"name":"sideshow-video-work","private":true}\n' > package.json +npm i @fontsource-variable/inter @fontsource/jetbrains-mono +cd .. + +# 1. record (server boot + seed + storyboard, prints the raw webm path) +node scripts/launch-video/record.mjs + +# 2. encode both deliverables +cd .video-work +RAW=$(ls page@*.webm) +ffmpeg -y -i "$RAW" -vf "fps=30,format=yuv420p" \ + -c:v libx264 -preset slow -crf 18 -movflags +faststart sideshow-X.Y.Z.mp4 +ffmpeg -y -i "$RAW" -vf "fps=30,format=yuv420p" \ + -c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 sideshow-X.Y.Z.webm +``` + +## Choosing a recipe + +- **Full release:** distill 3–5 user-visible headlines from the release's + `CHANGELOG.md` section (per-PR entries are too granular to shoot; confirm a + non-obvious shortlist with the user). Lead with what sideshow _is_ (the + publish → live render → comment → revise loop) before the release-specific + scenes — a social audience hasn't seen it before. End on an install card. +- **Single feature / PR:** intro card → one or two scenes demonstrating the + change → outro card. Name the output after the feature + (`sideshow-0.13-sidebar-rail.mp4`), keep it 15–30s, and keep the canonical + release storyboard in `record.mjs` intact — do the trim as a local edit and + revert, or copy `record.mjs` to a scratch sibling (imports keep working) and + delete it after. +- **Custom:** any scene set works — tutorials, comparisons, announcements. + Whatever runs in the viewer can be driven: publish over the API mid-recording + and the cards stream in on camera; that's the money shot, use it. + +## Storyboard model (record.mjs + stage.html) + +- The driver calls `window.stage` on the stage page between Playwright actions: + `stage("caption", html)` swaps the lower-third (slide out/in), + `stage("card", html)` shows a full-screen title card, `stage("hideCard")` + fades it out. Pacing is plain `sleep(ms)` between beats. +- Caption vocabulary: `NEW` amber pill, + `` amber highlight, `` muted. A NEW badge + is a claim about _this_ release — drop or move badges as features age. +- Card vocabulary: `badge`, `h1` (+ `span.ver` for the amber version), + `sub`, `cmds`/`cmd` (+ `span.p` for the prompt `$`), `foot`. +- Interact with the viewer through `page.frameLocator("#app")`. Useful + selectors: `aside .sess-title` (session rows), `.sidebar-toggle` + (collapse/expand rail), `.card:not(#whatsNew)` (post cards — the update-notes + card is also a `.card`), `button.act.comment` (the icon-only trigger that + unfolds the composer; it has no text, so `getByText` won't find it), then + `.composer input`. +- Publish content via the HTTP API while recording: `POST /api/sessions`, + `POST /api/posts` (`{session, title, surfaces: [{kind, …}]}`), + `PUT /api/posts/:id` to revise (bumps the version pill live), and + `POST /api/comments` (`{surface: postId, author, text}`) for agent replies. + Seed background sessions _before_ opening the page; save the live publishes + for on-camera. +- Target pacing: money shots hold 2.5–4s after content settles, transitions + ~1s, typing at `pressSequentially(..., { delay: 34 })`. Intro card ~3s, + outro ~4s. + +## Environment gotchas (each cost real debugging time) + +- **The viewer refuses to be framed.** Since 0.12.0 the viewer document sends + `Content-Security-Policy: frame-ancestors 'self'`, so the file:// stage's + iframe would be blocked. `record.mjs` strips that header with a Playwright + route on **exactly the viewer document URL**. Never widen the route pattern: + intercepting `/api/events` buffers the SSE stream forever and live updates + stop. +- **The title-card overlay must keep `pointer-events: none`.** Playwright's + hit-target check runs in the top frame; an overlay that intercepts clicks + aimed into the app iframe times every action out — even actions "behind" an + intro card. +- **Wait for iframes after a session switch.** Opening a session re-renders + each sandboxed surface from `/s/:id`; cut away too early and the last shot + shows empty card shells. `waitFor` a `.card iframe` and hold ~2–3s before + the outro (this is also why "publish on camera" shots need their settle + time). +- **Park the mouse after sidebar clicks** (`page.mouse.move(...)` toward the + content) or the hovered session row shows its delete "×" in every following + frame. +- **Sandbox Chromium/driver mismatch.** In the Anthropic sandbox the pinned + `@playwright/test` may expect a newer browser build than `/opt/pw-browsers` + provides; `record.mjs` falls back to `/opt/pw-browsers/chromium` + (override with `CHROMIUM_PATH`). Driving a slightly older Chromium works. +- **mp4 needs a real ffmpeg with libx264** (`apt-get install ffmpeg`; + `brew install ffmpeg` on macOS) — Playwright's bundled ffmpeg records the + raw webm (VP8) but cannot produce h264. Verify with + `ffmpeg -encoders | grep -E 'libx264|libvpx-vp9'`. +- **Give `.video-work/` its own `package.json` before `npm i`** or the fonts + land in the repo's `package.json` (revert with + `git checkout package.json package-lock.json` if that happens). +- The recorder spawns `server/index.ts` with `PORT=0` and `SIDESHOW_DB` in the + work dir — every run is a fresh workspace, nothing touches `~/.sideshow`. + The version being recorded matches `latest` on npm, so the `#whatsNew` + update card stays away on its own; scope card selectors with + `:not(#whatsNew)` anyway. +- A failed run can orphan the spawned server; kill it before rerunning + (`pkill -f "[s]erver/index.ts"`). + +## Content accuracy + +- **Verify install commands against reality**: `npm view sideshow dist-tags`. + The outro card's commands are `npm i -g sideshow` + `sideshow serve --open` + (or `npx sideshow serve --open`) — check they still match the README. +- Perf/number claims must come from the changelog entry, phrased no stronger + ("up to 95% lighter" for the 0.13.0 hydrate change, not "95% faster"). +- The window chrome's URL pill shows the real server host:port — decorative + but it must not lie; `record.mjs` fills it from the actual base URL. +- Demo content is `bin/demoData.js` (the `sideshow demo` sessions) — label + anything invented beyond it honestly, and keep agent names real + (`claude-code`, `pi`). +- The video is silent — never imply audio in the video or announcement copy. + +## Verification and delivery + +- After encoding, extract spot frames at each scene boundary and mid-scene: + `ffmpeg -y -ss -i sideshow-X.Y.Z.mp4 -frames:v 1 check.png` (Read + renders PNGs). Look specifically for: blank surface iframes (cut too early), + hover artifacts in the sidebar, captions overlapping scene changes, and the + intro/outro cards fully faded. Check duration with + `ffprobe -show_entries format=duration`. +- Outputs land in `.video-work/` (gitignored — never commit videos or the raw + recording). Send both files to the user (mp4: social/Slack; webm: web + embeds), report duration and sizes, and flag if the mp4 exceeds ~10 MB + (Slack) or ~15 MB (X). Copy elsewhere only if the user names a destination.