Regenerating Screenshots¶
The screenshots and videos in the README and on the Tour page are generated from the real UI. The pipeline renders the app in the Ladle UX sandbox against polished demo data, screenshots it with Playwright, records short scripted scenes, and encodes everything with ffmpeg. Everything runs in Docker, so you don't need a backend, a database or LiveKit. Regenerate the media whenever a UI change makes the current screenshots out of date.
Using Claude Code? The regenerate-media project skill walks through the same generate, review and publish loop, with the full review checklist and the known pitfalls.
Generate¶
docker compose --profile tools up -d ladle # the sandbox (first start compiles for ~30 s)
docker compose --profile tools run --rm media # screenshots + scenes, then encode
From a git worktree
The commands on this page use the dev stack, so run them in the main checkout. For media work on a branch in a git worktree, run the same pipeline with scripts/test-stack.sh <ticket> media (the same MEDIA_STEPS and MEDIA_FILTER variables). It serves the worktree's Ladle as <ticket>-ladle, with no host port, on the shared semaphore-test Docker network, and never creates a network (see Test Stacks). Restart it with docker restart <ticket>-ladle and remove it with scripts/test-stack.sh <ticket> down.
media waits for media-capture to finish (Playwright screenshots and scene recordings) and then encodes the results. Everything is written to frontend/.media-out/, which is gitignored:
| Path | What |
|---|---|
screenshots/*.webp | Desktop (1440×900) and phone (390×844) screenshots at 2× |
video/hero.webp, video/hero.gif | The ~15 s README loop (animated WebP, with a GIF fallback) |
video/tour.mp4, video/tour-poster.webp | The ~40 s tour video and its poster frame |
social.png | 1280×640 social / Open Graph card |
manifest.json | Every asset with its caption and alt text |
report.json | Console errors, unhandled requests, broken images and forbidden text for each screenshot |
raw/ | Unencoded PNGs and per-scene recordings (not published) |
Useful variables:
MEDIA_STEPS=shots,encoderuns only some steps (shots,record,encode). To re-encode only, add--no-depsso the capture container isn't started:MEDIA_STEPS=encode docker compose --profile tools run --rm --no-deps media.MEDIA_FILTER=voicelimits the capture to screenshots or scenes whose name contains the text. The encode step ignores it and rebuilds every output fromraw/, which takes about 15 s. A filtered run keeps the other screenshots and writes areport.jsonthat covers only the filtered ones, so finish with an unfiltered run (MEDIA_STEPS=shots,encodeis enough if no scene changed) before you publish.
Stop the sandbox afterwards with docker compose stop ladle && docker compose rm -f ladle media-capture. media-capture runs as a dependency of media, so --rm does not remove it.
Added a new story file?
Ladle only picks up new *.stories.tsx files after a restart (docker compose --profile tools restart ladle). Otherwise the capture shows "Story not found". The pipeline flags that page as forbidden text in report.json.
Review¶
Look at every output before you publish. Nothing checks taste automatically.
report.jsonmust show"issueCount": 0and"filter": null(an unfiltered run). Also checkraw/scenes/scenes.jsonfor page errors in the recordings.- Open every file in
screenshots/. Look for realistic content, no loading spinners, no broken images, no error or offline toasts, and no sandbox artefacts. The pipeline also rejects "SIMULATED", "Story not found" and similar text. - Watch
video/hero.webpin a browser. It should loop cleanly, and the cursor movement and typing should look human. -
Watch
video/tour.mp4, or pull out one frame per second onto a contact sheet to skim it (40 tiles, enough for the whole ~40 s tour): -
Check the size targets:
hero.webpshould be 2 MB or less,hero.gif5 MB or less, andtour.mp4a few MB.
Publish¶
The media lives on an orphan media branch, the same pattern as the coverage badges branch. main never contains images or videos. The README and the docs load files from https://raw.githubusercontent.com/semaphore-chat/semaphore-chat/media/<path>.
frontend/scripts/media/publish-media.sh --dry-run # build the commit locally and show what would be pushed
frontend/scripts/media/publish-media.sh # force-push it to origin/media
The script refuses to publish unless report.json comes from an unfiltered screenshot run with no issues, and screenshots/ holds exactly the screenshots in catalog.mjs. Each publish replaces the branch with a single new commit, so the branch history stays at one commit. Browsers and GitHub's image proxy can cache the old files for a few minutes after a publish.
Publish before the change reaches main
The README on main and the docs site (deployed on every docs-site/** push to main) load the media straight from the media branch. Publish when (or just before) a change that adds or renames media merges, otherwise those images and the video show as broken until someone does. Publishing a little early is fine, as long as the change doesn't rename or remove a file that main still uses (each publish replaces the whole branch).
social.png is not used by the README or the docs. After publishing a new one, upload it as the repository's social preview (Settings → General → Social preview on GitHub), which is what link previews of the repository show.
Changing what's captured¶
| To change | Edit |
|---|---|
| Demo people, channels, messages, DMs, notifications | frontend/src/stories/fixtures/showcase.ts |
| Avatars, community logos, attached images | frontend/src/stories/fixtures/showcaseArt.ts (hand-written SVG, generated locally) |
| GIFs (picker results and GIF messages) | frontend/src/stories/fixtures/showcaseGifs.ts (drawn on a canvas and encoded as GIFs in the browser, served from a mocked GIF provider) |
Theme, live socket and voice behaviour (window.__showcase) | frontend/src/stories/fixtures/showcaseStory.ts |
| Screens (one story per screenshot subject) | frontend/src/stories/tour/Tour.stories.tsx (story ids tour--*) |
| Which screenshots, viewports, captions and alt text | frontend/scripts/media/catalog.mjs |
| Video scenes (cursor paths, typing, teammates' actions) | frontend/scripts/media/record.mjs |
| Encoding, crossfades, sizes | frontend/scripts/media/encode.sh |
| Where the media is shown | README.md, docs-site/docs/tour.md, docs-site/docs/index.md |
Timestamps in the demo data are fixed wall-clock times, and the capture pins the browser clock and timezone to match. A "9:04 PM" in the data always renders as 9:04 PM.