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:
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.
# 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:
<span class="badge">NEW</span> amber pill,
<span class="hl"> amber highlight, <span class="dim"> 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 <t> -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.
1---2name: launch-video3description: 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.4---56# Sideshow video pipeline78Maintainer-only: requires a sideshow source checkout (the pipeline lives in9`scripts/launch-video/`, which never ships to npm).1011Generates product videos where every app frame is the real sideshow viewer12talking to a real server — no screen recording, no mockups. Unlike hunk's13keyframe-compositing pipeline (`skills/launch-video` in the hunk repo),14sideshow's product IS live motion — cards streaming in over SSE, sandboxed15iframes resizing, the comment loop — so this pipeline records **one real-time16pass** instead of compositing stills:1718```text19stage.html a 1920x1080 HTML stage: window chrome + caption lower-third +20 full-screen title cards, with the live viewer in an iframe21record.mjs boots a fresh server, seeds demo content, drives the storyboard22 with Playwright while recording video, prints the raw webm path23ffmpeg re-encodes the raw webm to mp4 (h264) and webm (vp9)24```2526Two halves live in `record.mjs`: reusable machinery (boot, seed, CSP strip,27stage resolution, browser/recording setup) and the **storyboard** — the scene28sequence, captions, and title cards. The storyboard is editorial content for29one video; rewrite it per video. As of this writing the checked-in reference30storyboard is the 0.13.0 release video.3132## Creating a video3334Expect a run to take roughly the video's length plus ~20s boot; encoding adds35a couple of minutes. Total output should stay near 40–60s.3637```sh38# 0. one-time work-dir setup (fonts are optional but much nicer than DejaVu)39mkdir -p .video-work && cd .video-work40printf '{"name":"sideshow-video-work","private":true}\n' > package.json41npm i @fontsource-variable/inter @fontsource/jetbrains-mono42cd ..4344# 1. record (server boot + seed + storyboard, prints the raw webm path)45node scripts/launch-video/record.mjs4647# 2. encode both deliverables48cd .video-work49RAW=$(ls page@*.webm)50ffmpeg -y -i "$RAW" -vf "fps=30,format=yuv420p" \51 -c:v libx264 -preset slow -crf 18 -movflags +faststart sideshow-X.Y.Z.mp452ffmpeg -y -i "$RAW" -vf "fps=30,format=yuv420p" \53 -c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 sideshow-X.Y.Z.webm54```5556## Choosing a recipe5758- **Full release:** distill 3–5 user-visible headlines from the release's59 `CHANGELOG.md` section (per-PR entries are too granular to shoot; confirm a60 non-obvious shortlist with the user). Lead with what sideshow _is_ (the61 publish → live render → comment → revise loop) before the release-specific62 scenes — a social audience hasn't seen it before. End on an install card.63- **Single feature / PR:** intro card → one or two scenes demonstrating the64 change → outro card. Name the output after the feature65 (`sideshow-0.13-sidebar-rail.mp4`), keep it 15–30s, and keep the canonical66 release storyboard in `record.mjs` intact — do the trim as a local edit and67 revert, or copy `record.mjs` to a scratch sibling (imports keep working) and68 delete it after.69- **Custom:** any scene set works — tutorials, comparisons, announcements.70 Whatever runs in the viewer can be driven: publish over the API mid-recording71 and the cards stream in on camera; that's the money shot, use it.7273## Storyboard model (record.mjs + stage.html)7475- The driver calls `window.stage` on the stage page between Playwright actions:76 `stage("caption", html)` swaps the lower-third (slide out/in),77 `stage("card", html)` shows a full-screen title card, `stage("hideCard")`78 fades it out. Pacing is plain `sleep(ms)` between beats.79- Caption vocabulary: `<span class="badge">NEW</span>` amber pill,80 `<span class="hl">` amber highlight, `<span class="dim">` muted. A NEW badge81 is a claim about _this_ release — drop or move badges as features age.82- Card vocabulary: `badge`, `h1` (+ `span.ver` for the amber version),83 `sub`, `cmds`/`cmd` (+ `span.p` for the prompt `$`), `foot`.84- Interact with the viewer through `page.frameLocator("#app")`. Useful85 selectors: `aside .sess-title` (session rows), `.sidebar-toggle`86 (collapse/expand rail), `.card:not(#whatsNew)` (post cards — the update-notes87 card is also a `.card`), `button.act.comment` (the icon-only trigger that88 unfolds the composer; it has no text, so `getByText` won't find it), then89 `.composer input`.90- Publish content via the HTTP API while recording: `POST /api/sessions`,91 `POST /api/posts` (`{session, title, surfaces: [{kind, …}]}`),92 `PUT /api/posts/:id` to revise (bumps the version pill live), and93 `POST /api/comments` (`{surface: postId, author, text}`) for agent replies.94 Seed background sessions _before_ opening the page; save the live publishes95 for on-camera.96- Target pacing: money shots hold 2.5–4s after content settles, transitions97 ~1s, typing at `pressSequentially(..., { delay: 34 })`. Intro card ~3s,98 outro ~4s.99100## Environment gotchas (each cost real debugging time)101102- **The viewer refuses to be framed.** Since 0.12.0 the viewer document sends103 `Content-Security-Policy: frame-ancestors 'self'`, so the file:// stage's104 iframe would be blocked. `record.mjs` strips that header with a Playwright105 route on **exactly the viewer document URL**. Never widen the route pattern:106 intercepting `/api/events` buffers the SSE stream forever and live updates107 stop.108- **The title-card overlay must keep `pointer-events: none`.** Playwright's109 hit-target check runs in the top frame; an overlay that intercepts clicks110 aimed into the app iframe times every action out — even actions "behind" an111 intro card.112- **Wait for iframes after a session switch.** Opening a session re-renders113 each sandboxed surface from `/s/:id`; cut away too early and the last shot114 shows empty card shells. `waitFor` a `.card iframe` and hold ~2–3s before115 the outro (this is also why "publish on camera" shots need their settle116 time).117- **Park the mouse after sidebar clicks** (`page.mouse.move(...)` toward the118 content) or the hovered session row shows its delete "×" in every following119 frame.120- **Sandbox Chromium/driver mismatch.** In the Anthropic sandbox the pinned121 `@playwright/test` may expect a newer browser build than `/opt/pw-browsers`122 provides; `record.mjs` falls back to `/opt/pw-browsers/chromium`123 (override with `CHROMIUM_PATH`). Driving a slightly older Chromium works.124- **mp4 needs a real ffmpeg with libx264** (`apt-get install ffmpeg`;125 `brew install ffmpeg` on macOS) — Playwright's bundled ffmpeg records the126 raw webm (VP8) but cannot produce h264. Verify with127 `ffmpeg -encoders | grep -E 'libx264|libvpx-vp9'`.128- **Give `.video-work/` its own `package.json` before `npm i`** or the fonts129 land in the repo's `package.json` (revert with130 `git checkout package.json package-lock.json` if that happens).131- The recorder spawns `server/index.ts` with `PORT=0` and `SIDESHOW_DB` in the132 work dir — every run is a fresh workspace, nothing touches `~/.sideshow`.133 The version being recorded matches `latest` on npm, so the `#whatsNew`134 update card stays away on its own; scope card selectors with135 `:not(#whatsNew)` anyway.136- A failed run can orphan the spawned server; kill it before rerunning137 (`pkill -f "[s]erver/index.ts"`).138139## Content accuracy140141- **Verify install commands against reality**: `npm view sideshow dist-tags`.142 The outro card's commands are `npm i -g sideshow` + `sideshow serve --open`143 (or `npx sideshow serve --open`) — check they still match the README.144- Perf/number claims must come from the changelog entry, phrased no stronger145 ("up to 95% lighter" for the 0.13.0 hydrate change, not "95% faster").146- The window chrome's URL pill shows the real server host:port — decorative147 but it must not lie; `record.mjs` fills it from the actual base URL.148- Demo content is `bin/demoData.js` (the `sideshow demo` sessions) — label149 anything invented beyond it honestly, and keep agent names real150 (`claude-code`, `pi`).151- The video is silent — never imply audio in the video or announcement copy.152153## Verification and delivery154155- After encoding, extract spot frames at each scene boundary and mid-scene:156 `ffmpeg -y -ss <t> -i sideshow-X.Y.Z.mp4 -frames:v 1 check.png` (Read157 renders PNGs). Look specifically for: blank surface iframes (cut too early),158 hover artifacts in the sidebar, captions overlapping scene changes, and the159 intro/outro cards fully faded. Check duration with160 `ffprobe -show_entries format=duration`.161- Outputs land in `.video-work/` (gitignored — never commit videos or the raw162 recording). Send both files to the user (mp4: social/Slack; webm: web163 embeds), report duration and sizes, and flag if the mp4 exceeds ~10 MB164 (Slack) or ~15 MB (X). Copy elsewhere only if the user names a destination.