Hunk video pipeline
Source-checkout only: the pipeline lives in scripts/launch-video/, which never ships to npm.
It can be used for pull-request demos as well as maintainer release videos. Unix-only — the
capture scripts exec /bin/bash.
Generates product videos where every terminal frame is the real Hunk TUI —
no screen recording, no mockups. Three stages:
capture.ts (bun) drive Hunk over a PTY, snap styled keyframes to PNG
compose.mjs (node + Playwright) render each PNG on a 1920x1080 HTML stage in Chromium
ffmpeg encode the composited PNGs at 30fps to mp4/webm
Playwright controls headless Chromium: for each planned frame it loads the
terminal PNG onto the HTML stage, applies the window chrome, cards, captions,
and transition state, then screenshots the completed stage back to PNG. ffmpeg
sequences those composited screenshots into the final videos.
The generic machinery (PTY driving, keyframe rendering, storyboard planning,
Chromium compositing, the stage template) is the @hunk/term-video workspace
package in packages/term-video/; scripts/launch-video/ holds only Hunk's
scenes, captions, and cards on top of it.
Choosing a recipe
The pipeline is not release-specific. Choose the editorial scope, then use the
same capture → composite → encode stages:
- Single feature: a short demonstration of one capability or workflow. Use
the single-feature recipe below and capture only the required scene.
- Full release: a multi-feature roundup based on a release's changelog or
highlights. Use the full-release recipe and update the canonical storyboard.
- Custom video: author any set of scenes and
SHOTS for tutorials,
comparisons, announcements, or workflow explainers; follow the scene and
storyboard rules below.
Creating a video
Expect ~3–6 min for capture and ~2–4 min for compose — run both with a long
timeout (or in the background); each logs per-snap / per-shot progress.
compose.mjs needs node ≥ 18 on PATH (bun alone is not enough).
# 0. dependencies (tuistory is a devDependency; ghostty-opentui arrives transitively)
bun install # if a postinstall hook fails in a sandbox, retry with --ignore-scripts
# 1. capture keyframes. Output defaults to <repo>/.video-work/ regardless of
# cwd (pass a path argument to override), but the PROCESS must run from the
# repo root — see gotchas.
bun run scripts/launch-video/capture.ts
# 2. one-time portable compositor setup. Playwright installs a Chromium build
# that exactly matches its browser driver (see gotchas to reuse a system or
# sandbox browser instead).
printf '{"name":"hunk-video-work","private":true}\n' > .video-work/package.json
cd .video-work
bun add playwright playwright-core
bunx playwright install chromium
cd ..
# 3. composite the storyboard
node scripts/launch-video/compose.mjs .video-work
# 4. encode
cd .video-work
ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
-c:v libx264 -preset slow -crf 18 -movflags +faststart launch.mp4
ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 launch.webm
Iterate on one scene without re-capturing the rest:
SCENES=review bun run scripts/launch-video/capture.ts # comma-separated scene names
Scene names are the wants("...") guards in capture.ts's main(). Note
SCENES= only narrows capture; compose.mjs preflights that every frame its
SHOTS table references exists in frames/ and fails fast listing any missing
ones, so a full composite still needs every scene captured at least once.
Single-feature recipe
For a short test, demo, or one-feature announcement, capture and composite only
the scene for that feature:
Pick one user-visible capability and find its scene name in the
wants("...") guards. If it does not have a scene, author one using the
guidance below. Capture only that scene:
SCENES=review bun run scripts/launch-video/capture.ts
Make a scratch compositor beside the canonical one so its imports and
repo-relative paths continue to work:
cp scripts/launch-video/compose.mjs scripts/launch-video/compose-one-feature.mjs
In the scratch copy, trim SHOTS to an opening card, only the selected
feature's frames, and an outro card. Rewrite those cards and captions for
the scoped cut. Sequence lengths must still match the captured frame names.
Composite into the same work directory, then run the normal ffmpeg commands
with descriptive output names:
node scripts/launch-video/compose-one-feature.mjs .video-work
cd .video-work
ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
-c:v libx264 -preset slow -crf 18 -movflags +faststart hunk-feature-demo.mp4
ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 hunk-feature-demo.webm
cd ..
rm scripts/launch-video/compose-one-feature.mjs
Keep the scratch compositor uncommitted. The canonical compose.mjs remains
the checked-in reference storyboard. If you added a capture scene only to make
PR evidence, revert that scene after encoding; retain it only when it is useful
checked-in demo coverage and belongs to the submitted change.
Full-release recipe
- Read the release section in
CHANGELOG.md. If it has a hand-written
Highlights list (0.18.0 has one; Changesets does not generate them), use
that list as the storyboard. Otherwise distill 4–6 user-visible headlines
from the Minor Changes — per-PR entries are too granular to shoot — and
confirm the shortlist with the user before capturing.
- Rewrite the canonical storyboard's editorial surface (next section), adding
or adjusting capture scenes as needed (see "Authoring scenes").
- Capture every scene referenced by the full storyboard, composite it, and
encode both formats using the main workflow above.
- Verify the complete cut (see "Verification") and deliver both files. When
hunk-release invoked this workflow, return the approved MP4 and WebM to that skill for versioned naming and GitHub user-attachment embedding; do not upload or edit the public release without its confirmation gate.
Per-video editorial surface
The capture machinery is reusable, but the storyboard is editorial content for
one video. Rewrite it to match the video's scope. As of this writing, the
checked-in reference storyboard is the full 0.18.0 release video:
compose.mjs: the whole SHOTS table; OPEN_CARD (version badge);
OUTRO_CARD (headline, install commands, footer); EXTENSIONS_CARD; every
<span class="badge">NEW</span> in captions — a NEW badge is a claim about
this release, so drop or move them as features age.
capture.ts: the scene functions and the wants() guards in main() are
the current storyboard's scene list, plus hunk-side glue (launchHunk,
launchHunkShell, createDemoRepo, the keyboard probe).
Reusable machinery lives in @hunk/term-video (packages/term-video/) —
extend it there, don't fork it into the scripts: createKeyframer,
launchApp/launchShell, createCommandWrapper, typeCommand,
ensureKeyboardIsLive, makeSceneFilter (src/capture.ts); the unit-tested
storyboard planner with the caption/timing semantics (src/plan.mjs);
composeStoryboard with font/Chromium resolution and the missing-keyframe
preflight (src/compose.mjs); and the stage template (src/stage.html).
Environment gotchas
Sandbox-specific bullets are marked; each cost real debugging time.
Run capture.ts with bun from the repo root. tuistory uses subpath
self-imports (tuistory/pty) that only resolve inside this repo's
node_modules; running the script from elsewhere resolves tuistory from
bun's global cache and crashes. (Output location is unaffected — it defaults
to <repo>/.video-work/ via import.meta.url.)
ghostty-opentui is a transitive dep (via tuistory) with an exports map:
import "ghostty-opentui/image" (not .../dist/image.js), resolved
relative to tuistory — @hunk/term-video/capture's createKeyframer does
the Bun.resolveSync dance.
Playwright must match the Chromium it drives. The portable setup above
installs playwright and playwright-core together, then downloads their
matching Chromium build. It works on macOS and Linux and is preferred when
bandwidth and browser downloads are available; leave CHROMIUM_PATH unset
so Playwright uses that managed browser. On Linux, if Chromium reports
missing system libraries, run bunx playwright install-deps chromium (it
may require sudo) before retrying.
To reuse an existing system, CI, or sandbox Chromium instead, install the
playwright-core version provided by that environment and set its executable
explicitly:
cd .video-work
bun add playwright-core@<matching-version>
cd ..
CHROMIUM_PATH=/path/to/chromium node scripts/launch-video/compose.mjs .video-work
Common executable locations include $(command -v chromium) or
$(command -v google-chrome) on Linux and
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome on macOS.
In an environment with a preinstalled Playwright toolchain, read that
toolchain's package.json to get the exact driver version. For example, the
Anthropic sandbox exposes it through /opt/pw-browsers/.links/*:
cat "$(cat /opt/pw-browsers/.links/* | head -1)/package.json" | grep '"version"'
# e.g. "1.56.1" -> bun add playwright-core@1.56.1
compose.mjs picks its browser as $CHROMIUM_PATH, then
/opt/pw-browsers/chromium when present, then Playwright's managed browser.
Give .video-work/ its own package.json before bun add. Without
one, bun walks up and installs into the repo's package.json — revert with
git checkout package.json bun.lock if that happens.
Chromium needs --allow-file-access-from-files (already in
compose.mjs): the stage samples each keyframe through a canvas to
color-match the window background, and file:// images taint the canvas
without it.
mp4 needs an ffmpeg with libx264. Sandbox: apt-get install ffmpeg
(run apt-get update first if packages 404). macOS: brew install ffmpeg.
Verify ffmpeg -encoders | grep -E 'libx264|libvpx-vp9' shows both before
encoding — playwright's bundled ffmpeg-*/ffmpeg-linux only does VP8/WebM
and cannot produce the mp4.
Caption font: JetBrains Mono ships inside ghostty-opentui;
findCaptionFont in packages/term-video/src/compose.mjs searches bun's
isolated layout (node_modules/.bun/node_modules/…) then a hoisted
node_modules/…. If it still throws caption font not found, locate the
file with find node_modules -name jetbrains-mono-nerd.ttf and pass it as
fontPath to composeStoryboard.
Authoring scenes (capture.ts)
- Shared geometry is 140x32 cells rendered at fontSize 16 / dpr 2 → 2688x1536
PNGs. Keep every scene at this size so all frames fit one window.
- Helpers:
createDemoRepo() and launchHunkShell() are hunk-side glue in
the script (git repo built from examples/2-mini-app-refactor; interactive
bash with a real hunk command on PATH and a clean ❯ prompt); snap,
typeCommand, launchApp/launchShell, and createCommandWrapper come
from @hunk/term-video/capture.
- Always
waitForText on scene-specific content before the first snap, and
call ensureKeyboardIsLive() before scripted keypresses — the first key
after startup can be dropped (real race, the helper toggles ? to prove
keys land).
- Animation = one snap per keypress. Cursor walks and typing effects are
just every
j/k/character captured as its own frame and played back at
0.2–0.3s per frame. Prefer this over sparse keyframes: three stills read as
a slideshow, per-press frames read as motion.
renderTerminalToImage auto-trims trailing blank rows, so short outputs
(CLI scenes) produce short PNGs — the stage handles this by sampling the
image's bottom-left pixel and painting the window body to match.
manifest.json is a capture-side inventory of the current run only;
compose.mjs ignores it (frames resolve by name from SHOTS), and after a
SCENES= run it is partial while frames/ stays cumulative.
- Demo content that must exist: STML notes come from
examples/9-agent-markup-notes (launch with --experimental), extension
scenes from examples/extensions/ loaded via --extension <path> (explicit
paths skip the repo trust prompt). The pager pipe is git diff | hunk pager
— bare hunk on piped stdin prints help. Sidebar toggle is s; comment
draft is c, save with Ctrl+S (\x13).
Storyboard model (compose.mjs)
SHOTS is the whole edit: one entry per shot, dur in seconds, played as
unique frames + per-frame durations in an ffmpeg concat list (holds cost one
frame, so runtime is dominated by transitions, not length).
capKey is caption identity: the caption slides in only when capKey
changes, and continuation shots that share a capKey without restating
caption keep the previous caption on screen. Sequences (walks, typing) are
generated with Array.from spreads.
- Sequence lengths must match capture loop bounds:
walk("j", 10) in
capture.ts produces review-walk-00..09, consumed by
Array.from({length: 9}) (+ the opening frame) in SHOTS. Change one side
and the other breaks — the preflight check names any frame that's missing.
enter: true fades/scales the surface in — use it for cards and the first
terminal shot only.
- Caption HTML vocabulary:
<span class="badge">NEW</span> amber pill,
<span class="hl"> amber highlight, <span class="dim"> muted. Cards use
badge / h1/h2 / sub / cmds+cmd / foot classes from
packages/term-video/src/stage.html.
- Target pacing: money shots hold 3–4s, context shots 2–3s, typing/walk frames
0.2–0.6s; keep the total near 60s.
Command and output legibility
Terminal commands and their results must be readable at normal playback size.
Never rely on a full-width terminal prompt or output region as the only way the
viewer can understand a command scene; text that looks acceptable in a 1080p
source frame is often illegible in an embedded player or social feed.
Use this default treatment:
- Present the exact command in large type. The easiest treatment is a
cmd card or an oversized editorial callout immediately before the real
terminal result. A typing animation may remain for motion, but it does not
replace the large command treatment.
- Zoom the real output. Crop or scale the captured terminal frame around
the meaningful result so both the command and the important output lines
are comfortably readable. A brief full-window establishing shot is fine,
but the result's main hold must use the focused view.
- Keep the evidence real. Never fabricate terminal output. Large command
text may reproduce the exact command as an editorial overlay; any enlarged
output must come from the captured PTY frame. If reusable crop/zoom controls
are missing, add them to
@hunk/term-video rather than baking one-off image
edits into a storyboard.
- Trim before shrinking. Prefer fewer relevant lines and a tighter crop
over fitting a long transcript into the frame. Split a workflow across
multiple focused shots when one crop cannot keep every important line
legible.
Treat command legibility as a release gate: if the command or its result cannot
be read when the 1920x1080 video is displayed at 50% size, revise the shot.
Content accuracy (learned the hard way)
- Verify install commands against reality, not the README: check
npm view hunkdiff dist-tags. A prerelease needs npm i -g hunkdiff@beta;
brew install hunk only serves stable (homebrew-core Autobump, lags npm) —
omit brew on prerelease cards.
- Label demo extensions as examples. The triage board, CSS palette, and
semver views are
examples/extensions/, not shipped features — caption them
with a dimmed example: prefix. The real features are the APIs (sidebars,
file views, commands, dialogs).
- Window titles are decorative but must not lie: the shell scenes run bash,
so keep their titles generic (
shell — …) rather than naming a shell the
capture doesn't launch.
- STML requires
--experimental; say so on the outro card.
- The video is silent — never imply audio in the video or its announcement copy.
Verification and delivery
Eyeball keyframes in .video-work/frames/ (Read renders PNGs) after
capture — especially new scenes — before compositing.
Inspect every command scene at 50% display size. Confirm the exact command is
shown in large type and the meaningful real output is zoomed tightly enough
to read; a readable caption does not compensate for unreadable terminal text.
After encoding, return to the repository root, set VIDEO to the produced
MP4, and inspect a mid-animation point, each new scene, and the outro:
VIDEO=.video-work/hunk-feature-demo.mp4 # or .video-work/launch.mp4
ffmpeg -y -ss 2 -i "$VIDEO" -frames:v 1 .video-work/check.png
ffprobe -show_entries format=duration "$VIDEO"
Captions must persist through the extracted animation frames.
Outputs stay under .video-work/: the full-release recipe creates
launch.mp4/launch.webm, while the single-feature recipe above creates
hunk-feature-demo.mp4/hunk-feature-demo.webm. .video-work/ is
gitignored — never commit the video or its frames. For a standalone video
request, send both files to the user directly (mp4: social/Slack; webm: web
embeds), report duration and file sizes, and flag if the mp4 exceeds ~10 MB
(Slack) or ~15 MB (X). When invoked by hunk-release, hand both files back
to that workflow instead; it owns versioned filenames, GitHub's
user-attachment limit, public embedding, and inline-player verification.
Copy them elsewhere only if the user names a destination.
1---2name: hunk-launch-video3description: Produces Hunk videos by driving the real TUI headlessly in a PTY, compositing captioned 1080p frames in Chromium, and encoding with ffmpeg. Use for feature demos, workflow explainers, announcements, launch videos, and full-release roundups.4---5
6# Hunk video pipeline
7
8Source-checkout only: the pipeline lives in `scripts/launch-video/`, which never ships to npm.
9It can be used for pull-request demos as well as maintainer release videos. Unix-only — the
10capture scripts exec `/bin/bash`.
11
12Generates product videos where every terminal frame is the real Hunk TUI —
13no screen recording, no mockups. Three stages:
14
15```text
16capture.ts (bun) drive Hunk over a PTY, snap styled keyframes to PNG
17compose.mjs (node + Playwright) render each PNG on a 1920x1080 HTML stage in Chromium
18ffmpeg encode the composited PNGs at 30fps to mp4/webm
19```
20
21Playwright controls headless Chromium: for each planned frame it loads the
22terminal PNG onto the HTML stage, applies the window chrome, cards, captions,
23and transition state, then screenshots the completed stage back to PNG. ffmpeg
24sequences those composited screenshots into the final videos.
25
26The generic machinery (PTY driving, keyframe rendering, storyboard planning,
27Chromium compositing, the stage template) is the `@hunk/term-video` workspace
28package in `packages/term-video/`; `scripts/launch-video/` holds only Hunk's
29scenes, captions, and cards on top of it.
30
31## Choosing a recipe
32
33The pipeline is not release-specific. Choose the editorial scope, then use the
34same capture → composite → encode stages:
35
36- **Single feature:** a short demonstration of one capability or workflow. Use
37 the single-feature recipe below and capture only the required scene.
38- **Full release:** a multi-feature roundup based on a release's changelog or
39 highlights. Use the full-release recipe and update the canonical storyboard.
40- **Custom video:** author any set of scenes and `SHOTS` for tutorials,
41 comparisons, announcements, or workflow explainers; follow the scene and
42 storyboard rules below.
43
44## Creating a video
45
46Expect ~3–6 min for capture and ~2–4 min for compose — run both with a long
47timeout (or in the background); each logs per-snap / per-shot progress.
48`compose.mjs` needs node ≥ 18 on PATH (bun alone is not enough).
49
50```sh
51# 0. dependencies (tuistory is a devDependency; ghostty-opentui arrives transitively)
52bun install # if a postinstall hook fails in a sandbox, retry with --ignore-scripts
53
54# 1. capture keyframes. Output defaults to <repo>/.video-work/ regardless of
55# cwd (pass a path argument to override), but the PROCESS must run from the
56# repo root — see gotchas.
57bun run scripts/launch-video/capture.ts
58
59# 2. one-time portable compositor setup. Playwright installs a Chromium build
60# that exactly matches its browser driver (see gotchas to reuse a system or
61# sandbox browser instead).
62printf '{"name":"hunk-video-work","private":true}\n' > .video-work/package.json
63cd .video-work
64bun add playwright playwright-core
65bunx playwright install chromium
66cd ..
67
68# 3. composite the storyboard
69node scripts/launch-video/compose.mjs .video-work
70
71# 4. encode
72cd .video-work
73ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
74 -c:v libx264 -preset slow -crf 18 -movflags +faststart launch.mp4
75ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
76 -c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 launch.webm
77```
78
79Iterate on one scene without re-capturing the rest:
80
81```sh
82SCENES=review bun run scripts/launch-video/capture.ts # comma-separated scene names
83```
84
85Scene names are the `wants("...")` guards in `capture.ts`'s `main()`. Note
86`SCENES=` only narrows _capture_; `compose.mjs` preflights that every frame its
87`SHOTS` table references exists in `frames/` and fails fast listing any missing
88ones, so a full composite still needs every scene captured at least once.
89
90## Single-feature recipe
91
92For a short test, demo, or one-feature announcement, capture and composite only
93the scene for that feature:
94
951. Pick one user-visible capability and find its scene name in the
96 `wants("...")` guards. If it does not have a scene, author one using the
97 guidance below. Capture only that scene:
98
99 ```sh
100 SCENES=review bun run scripts/launch-video/capture.ts
101 ```
102
1032. Make a scratch compositor beside the canonical one so its imports and
104 repo-relative paths continue to work:
105
106 ```sh
107 cp scripts/launch-video/compose.mjs scripts/launch-video/compose-one-feature.mjs
108 ```
109
1103. In the scratch copy, trim `SHOTS` to an opening card, only the selected
111 feature's frames, and an outro card. Rewrite those cards and captions for
112 the scoped cut. Sequence lengths must still match the captured frame names.
1134. Composite into the same work directory, then run the normal ffmpeg commands
114 with descriptive output names:
115
116 ```sh
117 node scripts/launch-video/compose-one-feature.mjs .video-work
118 cd .video-work
119 ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
120 -c:v libx264 -preset slow -crf 18 -movflags +faststart hunk-feature-demo.mp4
121 ffmpeg -y -f concat -safe 0 -i concat.txt -vf "fps=30,format=yuv420p" \
122 -c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 hunk-feature-demo.webm
123 cd ..
124 rm scripts/launch-video/compose-one-feature.mjs
125 ```
126
127Keep the scratch compositor uncommitted. The canonical `compose.mjs` remains
128the checked-in reference storyboard. If you added a capture scene only to make
129PR evidence, revert that scene after encoding; retain it only when it is useful
130checked-in demo coverage and belongs to the submitted change.
131
132## Full-release recipe
133
1341. Read the release section in `CHANGELOG.md`. If it has a hand-written
135 **Highlights** list (0.18.0 has one; Changesets does not generate them), use
136 that list as the storyboard. Otherwise distill 4–6 user-visible headlines
137 from the Minor Changes — per-PR entries are too granular to shoot — and
138 confirm the shortlist with the user before capturing.
1392. Rewrite the canonical storyboard's editorial surface (next section), adding
140 or adjusting capture scenes as needed (see "Authoring scenes").
1413. Capture every scene referenced by the full storyboard, composite it, and
142 encode both formats using the main workflow above.
1434. Verify the complete cut (see "Verification") and deliver both files. When `hunk-release` invoked this workflow, return the approved MP4 and WebM to that skill for versioned naming and GitHub user-attachment embedding; do not upload or edit the public release without its confirmation gate.
144
145## Per-video editorial surface
146
147The capture machinery is reusable, but the storyboard is editorial content for
148one video. Rewrite it to match the video's scope. As of this writing, the
149checked-in reference storyboard is the full 0.18.0 release video:
150
151- `compose.mjs`: the whole `SHOTS` table; `OPEN_CARD` (version badge);
152 `OUTRO_CARD` (headline, install commands, footer); `EXTENSIONS_CARD`; every
153 `<span class="badge">NEW</span>` in captions — a NEW badge is a claim about
154 _this_ release, so drop or move them as features age.
155- `capture.ts`: the scene functions and the `wants()` guards in `main()` are
156 the current storyboard's scene list, plus hunk-side glue (`launchHunk`,
157 `launchHunkShell`, `createDemoRepo`, the keyboard probe).
158
159Reusable machinery lives in `@hunk/term-video` (`packages/term-video/`) —
160extend it there, don't fork it into the scripts: `createKeyframer`,
161`launchApp`/`launchShell`, `createCommandWrapper`, `typeCommand`,
162`ensureKeyboardIsLive`, `makeSceneFilter` (`src/capture.ts`); the unit-tested
163storyboard planner with the caption/timing semantics (`src/plan.mjs`);
164`composeStoryboard` with font/Chromium resolution and the missing-keyframe
165preflight (`src/compose.mjs`); and the stage template (`src/stage.html`).
166
167## Environment gotchas
168
169Sandbox-specific bullets are marked; each cost real debugging time.
170
171- **Run `capture.ts` with bun from the repo root.** tuistory uses subpath
172 self-imports (`tuistory/pty`) that only resolve inside this repo's
173 `node_modules`; running the script from elsewhere resolves tuistory from
174 bun's global cache and crashes. (Output location is unaffected — it defaults
175 to `<repo>/.video-work/` via `import.meta.url`.)
176- **ghostty-opentui is a transitive dep** (via tuistory) with an exports map:
177 import `"ghostty-opentui/image"` (not `.../dist/image.js`), resolved
178 relative to tuistory — `@hunk/term-video/capture`'s `createKeyframer` does
179 the `Bun.resolveSync` dance.
180- **Playwright must match the Chromium it drives.** The portable setup above
181 installs `playwright` and `playwright-core` together, then downloads their
182 matching Chromium build. It works on macOS and Linux and is preferred when
183 bandwidth and browser downloads are available; leave `CHROMIUM_PATH` unset
184 so Playwright uses that managed browser. On Linux, if Chromium reports
185 missing system libraries, run `bunx playwright install-deps chromium` (it
186 may require sudo) before retrying.
187
188 To reuse an existing system, CI, or sandbox Chromium instead, install the
189 `playwright-core` version provided by that environment and set its executable
190 explicitly:
191
192 ```sh
193 cd .video-work
194 bun add playwright-core@<matching-version>
195 cd ..
196 CHROMIUM_PATH=/path/to/chromium node scripts/launch-video/compose.mjs .video-work
197 ```
198
199 Common executable locations include `$(command -v chromium)` or
200 `$(command -v google-chrome)` on Linux and
201 `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` on macOS.
202 In an environment with a preinstalled Playwright toolchain, read that
203 toolchain's `package.json` to get the exact driver version. For example, the
204 Anthropic sandbox exposes it through `/opt/pw-browsers/.links/*`:
205
206 ```sh
207 cat "$(cat /opt/pw-browsers/.links/* | head -1)/package.json" | grep '"version"'
208 # e.g. "1.56.1" -> bun add playwright-core@1.56.1
209 ```
210
211 `compose.mjs` picks its browser as `$CHROMIUM_PATH`, then
212 `/opt/pw-browsers/chromium` when present, then Playwright's managed browser.
213
214- **Give `.video-work/` its own `package.json` before `bun add`.** Without
215 one, bun walks up and installs into the repo's `package.json` — revert with
216 `git checkout package.json bun.lock` if that happens.
217- **Chromium needs `--allow-file-access-from-files`** (already in
218 `compose.mjs`): the stage samples each keyframe through a canvas to
219 color-match the window background, and file:// images taint the canvas
220 without it.
221- **mp4 needs an ffmpeg with libx264.** Sandbox: `apt-get install ffmpeg`
222 (run `apt-get update` first if packages 404). macOS: `brew install ffmpeg`.
223 Verify `ffmpeg -encoders | grep -E 'libx264|libvpx-vp9'` shows both before
224 encoding — playwright's bundled `ffmpeg-*/ffmpeg-linux` only does VP8/WebM
225 and cannot produce the mp4.
226- **Caption font**: JetBrains Mono ships inside ghostty-opentui;
227 `findCaptionFont` in `packages/term-video/src/compose.mjs` searches bun's
228 isolated layout (`node_modules/.bun/node_modules/…`) then a hoisted
229 `node_modules/…`. If it still throws `caption font not found`, locate the
230 file with `find node_modules -name jetbrains-mono-nerd.ttf` and pass it as
231 `fontPath` to `composeStoryboard`.
232
233## Authoring scenes (capture.ts)
234
235- Shared geometry is 140x32 cells rendered at fontSize 16 / dpr 2 → 2688x1536
236 PNGs. Keep every scene at this size so all frames fit one window.
237- Helpers: `createDemoRepo()` and `launchHunkShell()` are hunk-side glue in
238 the script (git repo built from `examples/2-mini-app-refactor`; interactive
239 bash with a real `hunk` command on PATH and a clean `❯` prompt); `snap`,
240 `typeCommand`, `launchApp`/`launchShell`, and `createCommandWrapper` come
241 from `@hunk/term-video/capture`.
242- Always `waitForText` on scene-specific content before the first snap, and
243 call `ensureKeyboardIsLive()` before scripted keypresses — the first key
244 after startup can be dropped (real race, the helper toggles `?` to prove
245 keys land).
246- **Animation = one snap per keypress.** Cursor walks and typing effects are
247 just every `j`/`k`/character captured as its own frame and played back at
248 0.2–0.3s per frame. Prefer this over sparse keyframes: three stills read as
249 a slideshow, per-press frames read as motion.
250- `renderTerminalToImage` auto-trims trailing blank rows, so short outputs
251 (CLI scenes) produce short PNGs — the stage handles this by sampling the
252 image's bottom-left pixel and painting the window body to match.
253- `manifest.json` is a capture-side inventory of the _current run_ only;
254 `compose.mjs` ignores it (frames resolve by name from `SHOTS`), and after a
255 `SCENES=` run it is partial while `frames/` stays cumulative.
256- Demo content that must exist: STML notes come from
257 `examples/9-agent-markup-notes` (launch with `--experimental`), extension
258 scenes from `examples/extensions/` loaded via `--extension <path>` (explicit
259 paths skip the repo trust prompt). The pager pipe is `git diff | hunk pager`
260 — bare `hunk` on piped stdin prints help. Sidebar toggle is `s`; comment
261 draft is `c`, save with Ctrl+S (`\x13`).
262
263## Storyboard model (compose.mjs)
264
265- `SHOTS` is the whole edit: one entry per shot, `dur` in seconds, played as
266 unique frames + per-frame durations in an ffmpeg concat list (holds cost one
267 frame, so runtime is dominated by transitions, not length).
268- `capKey` is caption identity: the caption slides in only when `capKey`
269 changes, and continuation shots that share a `capKey` without restating
270 `caption` keep the previous caption on screen. Sequences (walks, typing) are
271 generated with `Array.from` spreads.
272- **Sequence lengths must match capture loop bounds**: `walk("j", 10)` in
273 `capture.ts` produces `review-walk-00..09`, consumed by
274 `Array.from({length: 9})` (+ the opening frame) in `SHOTS`. Change one side
275 and the other breaks — the preflight check names any frame that's missing.
276- `enter: true` fades/scales the surface in — use it for cards and the first
277 terminal shot only.
278- Caption HTML vocabulary: `<span class="badge">NEW</span>` amber pill,
279 `<span class="hl">` amber highlight, `<span class="dim">` muted. Cards use
280 `badge` / `h1`/`h2` / `sub` / `cmds`+`cmd` / `foot` classes from
281 `packages/term-video/src/stage.html`.
282- Target pacing: money shots hold 3–4s, context shots 2–3s, typing/walk frames
283 0.2–0.6s; keep the total near 60s.
284
285## Command and output legibility
286
287Terminal commands and their results must be readable at normal playback size.
288Never rely on a full-width terminal prompt or output region as the only way the
289viewer can understand a command scene; text that looks acceptable in a 1080p
290source frame is often illegible in an embedded player or social feed.
291
292Use this default treatment:
293
2941. **Present the exact command in large type.** The easiest treatment is a
295 `cmd` card or an oversized editorial callout immediately before the real
296 terminal result. A typing animation may remain for motion, but it does not
297 replace the large command treatment.
2982. **Zoom the real output.** Crop or scale the captured terminal frame around
299 the meaningful result so both the command and the important output lines
300 are comfortably readable. A brief full-window establishing shot is fine,
301 but the result's main hold must use the focused view.
3023. **Keep the evidence real.** Never fabricate terminal output. Large command
303 text may reproduce the exact command as an editorial overlay; any enlarged
304 output must come from the captured PTY frame. If reusable crop/zoom controls
305 are missing, add them to `@hunk/term-video` rather than baking one-off image
306 edits into a storyboard.
3074. **Trim before shrinking.** Prefer fewer relevant lines and a tighter crop
308 over fitting a long transcript into the frame. Split a workflow across
309 multiple focused shots when one crop cannot keep every important line
310 legible.
311
312Treat command legibility as a release gate: if the command or its result cannot
313be read when the 1920x1080 video is displayed at 50% size, revise the shot.
314
315## Content accuracy (learned the hard way)
316
317- **Verify install commands against reality**, not the README: check
318 `npm view hunkdiff dist-tags`. A prerelease needs `npm i -g hunkdiff@beta`;
319 `brew install hunk` only serves stable (homebrew-core Autobump, lags npm) —
320 omit brew on prerelease cards.
321- **Label demo extensions as examples.** The triage board, CSS palette, and
322 semver views are `examples/extensions/`, not shipped features — caption them
323 with a dimmed `example:` prefix. The real features are the APIs (sidebars,
324 file views, commands, dialogs).
325- Window titles are decorative but must not lie: the shell scenes run bash,
326 so keep their titles generic (`shell — …`) rather than naming a shell the
327 capture doesn't launch.
328- STML requires `--experimental`; say so on the outro card.
329- The video is silent — never imply audio in the video or its announcement copy.
330
331## Verification and delivery
332
333- Eyeball keyframes in `.video-work/frames/` (Read renders PNGs) after
334 capture — especially new scenes — before compositing.
335- Inspect every command scene at 50% display size. Confirm the exact command is
336 shown in large type and the meaningful real output is zoomed tightly enough
337 to read; a readable caption does not compensate for unreadable terminal text.
338- After encoding, return to the repository root, set `VIDEO` to the produced
339 MP4, and inspect a mid-animation point, each new scene, and the outro:
340
341 ```sh
342 VIDEO=.video-work/hunk-feature-demo.mp4 # or .video-work/launch.mp4
343 ffmpeg -y -ss 2 -i "$VIDEO" -frames:v 1 .video-work/check.png
344 ffprobe -show_entries format=duration "$VIDEO"
345 ```
346
347 Captions must persist through the extracted animation frames.
348
349- Outputs stay under `.video-work/`: the full-release recipe creates
350 `launch.mp4`/`launch.webm`, while the single-feature recipe above creates
351 `hunk-feature-demo.mp4`/`hunk-feature-demo.webm`. `.video-work/` is
352 gitignored — never commit the video or its frames. For a standalone video
353 request, send both files to the user directly (mp4: social/Slack; webm: web
354 embeds), report duration and file sizes, and flag if the mp4 exceeds ~10 MB
355 (Slack) or ~15 MB (X). When invoked by `hunk-release`, hand both files back
356 to that workflow instead; it owns versioned filenames, GitHub's
357 user-attachment limit, public embedding, and inline-player verification.
358 Copy them elsewhere only if the user names a destination.