Record demos with vhs and publish them to GitHub's attachment store

The demo gifs in the README start on their own, loop without telling the
reader where a run begins, and give no way to pause, seek or replay. A
video with the browser's own controls fixes all three, but GitHub plays
a video in a README only when it is served from its own attachment
store. If you commit one to the assets branch and link it the way we
link the images, GitHub drops the whole <video> element when it renders
the page.

Replace terminalizer with vhs. vhs records the demo straight to mp4
rather than going through a gif, and it takes the terminal size, font
and colours from demo/settings.tape. Then upload the result from the
endpoint that GitHub's own drag-and-drop upload posts to, and print the
tag to paste into the page.

Uploading alone is not enough. An attachment is readable only by people
who are signed in to GitHub until a posted comment in the repository
refers to it, and a README on a branch does not count. So post each
recording to a collecting issue and wait until the video can be fetched
without a token. Miss that step and the video plays for whoever recorded
it and 404s for every other reader.

Two things about the frame. Pad the bottom, because the browser draws
its playback controls over the video and they are tall enough to cover
the line where the demos put their captions. And cut the end: vhs
records until the marker reaches the screen, by which time lazygit has
exited and the shell has painted its prompt back over the demo. A
browser holds the last frame of a video once it has played to the end,
so leaving those frames in would end every demo on a terminal prompt
and leave it there.

Pass --no-upload while you are still working on how a demo looks. That
writes the video to demo/output and stops, so trying out a colour or a
font costs nothing but the recording itself.

The recording is sharper and smaller than the gif it replaces, at
1866x1230 and 25 fps against 1140x828 and about 5 fps. It also costs the
reader nothing until they press play, whereas the gifs are fetched every
time the README is opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-04 19:01:03 +02:00
co-authored by Claude Opus 5
parent a03709df50
commit f9067ca3a5
4 changed files with 310 additions and 183 deletions
-112
View File
@@ -1,112 +0,0 @@
# Specify a command to be executed
# like `/bin/bash -l`, `ls`, or any other commands
# the default is bash for Linux
# or powershell.exe for Windows
command: echo "YOU NEED TO SPECIFY YOUR OWN COMMAND WITH THE -d ARG"
# Specify the current working directory path
# the default is the current working directory path
cwd: null
# Export additional ENV variables
env:
recording: true
# Explicitly set the number of columns
# or use `auto` to take the current
# number of columns of your shell
cols: 120 # 100
# Explicitly set the number of rows
# or use `auto` to take the current
# number of rows of your shell
rows: 35 # 30
# Amount of times to repeat GIF
# If value is -1, play once
# If value is 0, loop indefinitely
# If value is a positive number, loop n times
repeat: 0
# Quality
# 1 - 100
# Higher quality seems to make no difference, but running it through
# gifsicle ends up with a much better compressed version.
quality: 100
# Delay between frames in ms
# If the value is `auto` use the actual recording delays
frameDelay: auto
# Maximum delay between frames in ms
# Ignored if the `frameDelay` isn't set to `auto`
# Set to `auto` to prevent limiting the max idle time
maxIdleTime: 2000
# The surrounding frame box
# The `type` can be null, window, floating, or solid`
# To hide the title use the value null
# Don't forget to add a backgroundColor style with a null as type
frameBox:
type: floating
title: Lazygit
style:
border: 0px black solid
backgroundColor: "#1d1d1d"
margin: -5px
# Add a watermark image to the rendered gif
# You need to specify an absolute path for
# the image on your machine or a URL, and you can also
# add your own CSS styles
watermark:
imagePath: null
style:
position: absolute
right: 15px
bottom: 15px
width: 100px
opacity: 0.9
# Cursor style can be one of
# `block`, `underline`, or `bar`
cursorStyle: block
# Font family
# You can use any font that is installed on your machine
# in CSS-like syntax
# Download from:
# https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.zip
# Not using the mono font because it makes icons too small.
fontFamily: "DejaVuSansM Nerd Font"
# The size of the font
fontSize: 8
# The height of lines
lineHeight: 1
# The spacing between letters
letterSpacing: 0
# Theme
theme:
background: "transparent"
foreground: "#dddad6"
cursor: "#c7c7c7"
black: "#7a7a7a"
red: "#fc4384"
green: "#b3e33b"
yellow: "#ffa727"
blue: "#102895"
magenta: "#c930c7"
cyan: "#00c5c7"
white: "#c7c7c7"
brightBlack: "#676767"
brightRed: "#ff7fac"
brightGreen: "#c8ed71"
brightYellow: "#ebdf86"
brightBlue: "#6871ff"
brightMagenta: "#ff76ff"
brightCyan: "#5ffdff"
brightWhite: "#fffefe"
+213 -46
View File
@@ -2,55 +2,72 @@
set -e
TYPE=$1
TEST=$2
# The repository the demo is uploaded to. GitHub only plays videos that live in
# its own attachment store, and an attachment is tied to one repository.
REPO=jesseduffield/lazygit
# The issue that collects the demo recordings. Posting a comment there is what
# makes an uploaded video readable by people who are not signed in to GitHub.
# It can stay closed; commenting on a closed issue publishes the video just as
# well, and does not reopen it.
PUBLISH_ISSUE=6051
usage() {
echo "Usage: $0 [gif|mp4] <test path>"
echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go"
echo "Usage: $0 [--no-upload] <test path>"
echo "e.g. $0 pkg/integration/tests/demo/nuke_working_tree.go"
echo
echo "--no-upload leaves the video in demo/output and stops there, for"
echo "checking how a change to demo/settings.tape turns out."
exit 1
}
if [ "$#" -ne 2 ]
UPLOAD=true
if [ "$1" = "--no-upload" ]
then
UPLOAD=false
shift
fi
TEST=$1
if [ "$#" -ne 1 ]
then
usage
fi
if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ]
TOOLS="vhs ttyd ffmpeg"
if [ "$UPLOAD" = true ]
then
usage
exit 1
TOOLS="$TOOLS gh"
fi
if [ -z "$TEST" ]
for TOOL in $TOOLS
do
if ! command -v "$TOOL" > /dev/null 2>&1
then
echo "$TOOL could not be found"
echo "Install it with: brew install $TOOL"
exit 1
fi
done
if [ "$UPLOAD" = true ]
then
usage
fi
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
if [ -z "$WORKTREE_PATH" ]
then
echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
echo "git worktree add .worktrees/assets assets"
echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
exit 1
fi
if [ -z "$WORKTREE_PATH" ]
then
echo "Could not find assets worktree. You'll need to create a worktree for the assets branch using the following command:"
echo "git worktree add .worktrees/assets assets"
echo "The assets branch has no shared history with the main branch: it exists to store assets which are too large to store in the main branch."
exit 1
fi
OUTPUT_DIR="$WORKTREE_PATH/demo"
if ! command -v terminalizer &> /dev/null
then
echo "terminalizer could not be found"
echo "Install it with: npm install -g terminalizer"
exit 1
fi
if ! command -v "gifsicle" &> /dev/null
then
echo "gifsicle could not be found"
echo "Install it with: npm install -g gifsicle"
exit 1
OUTPUT_DIR="$WORKTREE_PATH/demo"
else
OUTPUT_DIR=demo/output
fi
# Get last part of the test path and set that as the output name
@@ -63,19 +80,169 @@ go generate pkg/integration/tests/tests.go
mkdir -p "$OUTPUT_DIR"
# First we record the demo into a yaml representation
terminalizer -c demo/config.yml record --skip-sharing -d "go run cmd/integration_test/main.go cli --slow $TEST" "$OUTPUT_DIR/$NAME"
# Then we render it into a gif
terminalizer render "$OUTPUT_DIR/$NAME" -o "$OUTPUT_DIR/$NAME.gif"
SCRATCH=$(mktemp -d)
trap 'rm -rf "$SCRATCH"' EXIT
# Then we convert it to either an mp4 or gif based on the command line argument
if [ "$TYPE" = "mp4" ]
TAPE="$SCRATCH/$NAME.tape"
RECORDING="$SCRATCH/$NAME.mp4"
OUTPUT="$OUTPUT_DIR/$NAME.mp4"
# Start recording once lazygit has drawn the top left corner of a view frame.
# Demos run in whichever screen mode they ask for, so no particular panel is
# on screen for all of them, but every view is drawn with a frame. This is the
# corner that `border: rounded` draws; a demo config that turns borders off
# would need a different signal.
#
# The two quotes in the end marker keep the literal VHSDONE out of the command
# line that stays on screen while we wait for the marker to be printed.
cat > "$TAPE" <<EOF
Output "$RECORDING"
Source demo/settings.tape
# The command is typed while the recording is hidden, so there is nothing to
# gain from animating it.
Set TypingSpeed 0ms
Hide
Type "go run cmd/integration_test/main.go cli --slow $TEST; echo VHS''DONE"
Enter
Wait+Screen@180s /╭/
Show
Wait+Screen@600s /VHSDONE/
Hide
EOF
vhs "$TAPE"
if [ ! -f "$RECORDING" ]
then
COMPRESSED_PATH="$OUTPUT_DIR/$NAME.mp4"
ffmpeg -y -i "$OUTPUT_DIR/$NAME.gif" -movflags faststart -pix_fmt yuv420p -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" "$COMPRESSED_PATH"
else
COMPRESSED_PATH="$OUTPUT_DIR/$NAME-compressed.gif"
gifsicle --colors 256 --use-col=web -O3 < "$OUTPUT_DIR/$NAME.gif" > "$COMPRESSED_PATH"
echo "vhs recorded the demo but wrote no video."
echo "vhs 0.12.0 does this; see https://github.com/charmbracelet/vhs/issues/787."
echo "Install a working version with: go install github.com/charmbracelet/vhs@v0.11.0"
exit 1
fi
echo "Demo recorded to $COMPRESSED_PATH"
# The browser draws its playback controls over the bottom of the video, and
# they are tall enough to hide lazygit's caption line. Pad the frame so that
# the caption sits above them. Chrome draws the tallest bar of the three, and
# at the width a README gives the video its buttons start to overlap the
# caption below about 90px, so there is not much room to trim here. Measure it
# again if demo/settings.tape changes the size of the recording.
CAPTION_CLEARANCE=90
BACKGROUND=$(sed -n 's/.*"background": *"\(#[0-9a-fA-F]*\)".*/\1/p' demo/settings.tape)
if [ -z "$BACKGROUND" ]
then
echo "Could not read the background colour from demo/settings.tape"
exit 1
fi
# vhs keeps recording until the marker reaches the screen, and by then lazygit
# has exited and the shell has painted its prompt back over the demo. Find the
# moment that happened so we can cut it off. Measure the share of pixels that
# are not background: it collapses when lazygit's panels give way to a prompt,
# and the reading holds steady from there to the end of the recording.
CUT=$(ffmpeg -v error -i "$RECORDING" \
-vf "format=gray,lutyuv=y='if(gt(val\,60)\,255\,0)',signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-" \
-an -f null - 2>/dev/null |
# ffmpeg writes these numbers with a decimal point, so keep awk in a
# locale that reads them back that way. The + 0 turns them from strings
# into numbers, without which awk compares them as text.
LC_ALL=C awk '/pts_time:/ { split($3, a, ":"); time = a[2] }
/YAVG=/ { split($0, b, "="); n++; at[n] = time + 0; ink[n] = b[2] + 0 }
END {
# Walk back over the frames that read the same as the last one.
k = n
while (k > 1 && ink[k - 1] < ink[n] * 1.05 && ink[k - 1] > ink[n] * 0.95) {
k--
}
# Only call it a prompt if the screen really did empty out. A
# demo that simply ends on a still frame leaves nothing to cut.
if (k > 1 && ink[k - 1] > ink[n] * 2) {
printf "%.3f\n", at[k]
}
}')
if [ -n "$CUT" ]
then
TRIM="-t $CUT"
else
TRIM=
fi
# The video ends on the last frame of the demo. A browser goes on showing that
# frame once it has played to the end, so it needs no padding out. Move the
# moov atom to the front so that playback can start before the whole file has
# downloaded.
# shellcheck disable=SC2086
ffmpeg -y -loglevel error -i "$RECORDING" $TRIM \
-vf "pad=iw:ih+$CAPTION_CLEARANCE:0:0:color=$BACKGROUND" \
-c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p \
-movflags +faststart -an "$OUTPUT"
if [ "$UPLOAD" = false ]
then
echo "Demo recorded to $OUTPUT"
exit 0
fi
# GitHub's web editor posts to this endpoint when you drag a file into a
# comment box. It is undocumented, but it accepts an ordinary token, so we can
# upload from here. You need push access to $REPO for it to work. If the
# endpoint ever goes away, drag the video into a comment box on github.com
# instead and copy the URL that GitHub inserts.
REPOSITORY_ID=$(gh api "repos/$REPO" --jq .id)
RESPONSE=$(curl --silent --show-error --fail \
--request POST \
--header "Authorization: Bearer $(gh auth token)" \
--header "Accept: application/json" \
--header "Content-Type: video/mp4" \
--data-binary "@$OUTPUT" \
"https://uploads.github.com/user-attachments/assets?name=$NAME.mp4&content_type=video%2Fmp4&repository_id=$REPOSITORY_ID")
URL=$(echo "$RESPONSE" | sed -e 's/.*"url":"//' -e 's/".*//')
if [ -z "$URL" ]
then
echo "Could not read an attachment URL out of GitHub's response:"
echo "$RESPONSE"
exit 1
fi
# An attachment stays private until a posted comment somewhere in the
# repository refers to it. Until that happens the video is a 404 for anyone who
# is not signed in, and the README shows a broken player. Referring to it once
# makes it public for good, even if the comment is deleted afterwards, so we
# collect the recordings in one issue and leave the comments in place.
gh api "repos/$REPO/issues/$PUBLISH_ISSUE/comments" \
--raw-field "body=$NAME
$URL" > /dev/null
# Make sure that worked before handing over a URL, because the person recording
# the demo is signed in and will not see the failure.
ATTEMPT=0
while [ "$ATTEMPT" -lt 30 ]
do
if curl --silent --fail --output /dev/null --max-time 20 --range 0-1 "$URL"
then
break
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 2
done
if [ "$ATTEMPT" -eq 30 ]
then
echo "$URL is still not readable without signing in to GitHub."
echo "Embedding it now would give logged-out readers a broken player."
exit 1
fi
echo "Demo recorded to $OUTPUT"
echo
echo "Embed it with:"
echo "<video src=\"$URL\" controls></video>"
+46
View File
@@ -0,0 +1,46 @@
# Terminal settings shared by all demo recordings. The tape that
# demo/record_demo.sh generates for a demo sources this file.
# Use the non-Mono Nerd Font variant; the Mono variant renders the icons too
# small.
Set FontFamily "SauceCodePro NF"
Set FontSize 24
Set LineHeight 1.0
Set Padding 20
# vhs sizes the terminal in pixels rather than in cells, so these two numbers
# are what produce a 120x35 grid at the font settings above. If you change the
# font, the font size or the padding, record a tape that runs `stty size` and
# adjust them until the grid is 120x35 again.
Set Width 1866
Set Height 1140
# There is no frame rate setting here because vhs ignores `Set Framerate` when
# it writes a video; it captures at 25 fps either way.
# The frame around the focused view is bold green, and xterm.js draws bold text
# in the bright variant of a colour, so brightGreen is the one those frames end
# up using.
Set Theme {
"background": "#1d1d1d",
"foreground": "#dddad6",
"cursor": "#c7c7c7",
"selection": "#44475a",
"black": "#7a7a7a",
"red": "#fc4384",
"green": "#3fb950",
"yellow": "#ffa727",
"blue": "#102895",
"magenta": "#c930c7",
"cyan": "#00c5c7",
"white": "#c7c7c7",
"brightBlack": "#676767",
"brightRed": "#ff7fac",
"brightGreen": "#56d364",
"brightYellow": "#ebdf86",
"brightBlue": "#6871ff",
"brightMagenta": "#ff76ff",
"brightCyan": "#5ffdff",
"brightWhite": "#fffefe"
}
+51 -25
View File
@@ -8,17 +8,18 @@ You'll want to familiarise yourself with how integration tests are written: see
Ideally we'd run this whole thing through docker but we haven't got that working. So you will need:
```
# for recording
npm i -g terminalizer
# for gif compression
npm i -g gifsicle
# for mp4 conversion
brew install ffmpeg
# for recording; vhs drives ttyd and ffmpeg under the hood
brew install ttyd ffmpeg
# vhs 0.12.0 runs the tape, reports success and writes no video at all
# (https://github.com/charmbracelet/vhs/issues/787), so pin the release
# before it
go install github.com/charmbracelet/vhs@v0.11.0
# font with icons
wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/DejaVuSansMono.tar.xz && \
tar -xf DejaVuSansMono.tar.xz -C /usr/local/share/fonts && \
rm DejaVuSansMono.tar.xz
wget https://github.com/ryanoasis/nerd-fonts/releases/download/v3.0.2/SourceCodePro.tar.xz && \
tar -xf SourceCodePro.tar.xz -C ~/Library/Fonts && \
rm SourceCodePro.tar.xz
```
## Creating a demo
@@ -49,34 +50,59 @@ The scripts and demo definitions live in the code branches but the output lives
git worktree add .worktrees/assets assets
```
Outputs will be stored in `.worktrees/assets/demos/`. We'll store three separate things:
* the yaml of the recording
* the original gif
* either the compressed gif or the mp4 depending on the output you chose (see below)
The mp4 of the recording will be stored in `.worktrees/assets/demo/`.
### Recording the demo
Once you're happy with your demo you can record it using:
```sh
scripts/record_demo.sh [gif|mp4] <path>
scripts/record_demo.sh <path>
# e.g.
scripts/record_demo.sh gif pkg/integration/tests/demo/interactive_rebase.go
scripts/record_demo.sh pkg/integration/tests/demo/interactive_rebase.go
```
~~The gif format is for use in the first video of the readme (it has a larger size but has auto-play and looping)~~
~~The mp4 format is for everything else (no looping, requires clicking, but smaller size).~~
The terminal size, font and colours live in `demo/settings.tape`, which the
script sources into the tape it generates for the demo.
Turns out that you can't store mp4s in a repo and link them from a README so we're gonna just use gifs across the board for now.
While you are still working on how a demo looks, pass `--no-upload`. That
leaves the video in `demo/output` (which is git-ignored) and stops there, so
you can watch it without uploading anything or touching the assets worktree:
```sh
scripts/record_demo.sh --no-upload pkg/integration/tests/demo/interactive_rebase.go
```
### Including demos in README/docs
If you've followed the above steps you'll end up with your output in your assets worktree.
Recording a demo does three things with the mp4: it writes it to your assets
worktree, it uploads a copy to GitHub's attachment store, and it posts that
copy as a comment on the issue named by `PUBLISH_ISSUE` in the script. Then it
prints the tag to embed:
Within that worktree, stage all three output files and raise a PR against the assets branch.
Then back in the code branch, in the doc, you can embed the recording like so:
```md
![Nuke working tree](../assets/demo/interactive_rebase-compressed.gif)
```html
<video src="https://github.com/user-attachments/assets/<uuid>" controls></video>
```
This means we can update assets without needing to update the docs that embed them.
GitHub plays a video in a README only when it is served from its own attachment
store. If you commit a video to the assets branch and link it the way we link
the images, GitHub drops the whole `<video>` element when it renders the page.
So the README reads the uploaded copy rather than the one in the assets
worktree. Keep that one anyway, so that we still have the file if we ever need
to upload it again. Stage it and raise a PR against the assets branch as you
would for any other asset.
Attachment URLs are opaque and have no path we can predict, so a new recording
of an existing demo means a new URL and an edit to the page that embeds it.
That comment on `PUBLISH_ISSUE` is not bookkeeping; it is what makes the video
watchable. An uploaded attachment is readable only by people signed in to
GitHub until some posted comment in the repository refers to it, and a README
on a branch does not count. Skip that step and the video plays for you and
404s for everyone else, which is easy to miss because you are signed in. The
script waits until the video can be fetched without a token before it prints
the tag. Referring to an attachment once is enough and cannot be undone, so
the comments could be deleted later, but leaving them gives us a dated list of
every recording.
Uploading needs push access to the lazygit repository. If you don't have it,
record the demo, then ask a maintainer to upload the mp4 for you.