Record demos with vhs and upload 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.

The recording is sharper and smaller than the gif it replaces. It runs
at 1866x1140 and 25 fps for 297K, against 1140x828 and about 5 fps for
461K. 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-09-22 20:32:07 +02:00
co-authored by Claude Opus 5
parent 2c74c8d6d3
commit efdf8b360c
4 changed files with 144 additions and 179 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"
+86 -41
View File
@@ -2,30 +2,32 @@
set -e
TYPE=$1
TEST=$2
TEST=$1
# 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
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 <test path>"
echo "e.g. $0 pkg/integration/tests/demo/nuke_working_tree.go"
exit 1
}
if [ "$#" -ne 2 ]
if [ "$#" -ne 1 ]
then
usage
fi
if [ "$TYPE" != "gif" ] && [ "$TYPE" != "mp4" ]
then
usage
exit 1
fi
if [ -z "$TEST" ]
then
usage
fi
for TOOL in vhs ttyd ffmpeg gh
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
WORKTREE_PATH=$(git worktree list | grep assets | awk '{print $1}')
@@ -39,20 +41,6 @@ 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
fi
# Get last part of the test path and set that as the output name
# example test path: pkg/integration/tests/01_basic_test.go
# For that we want: NAME=01_basic_test
@@ -63,19 +51,76 @@ 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"
# The two quotes in the 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 /Local branches/
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"
# Hold the last frame for a moment so that the end state stays readable, and
# move the moov atom to the front so that the video starts playing before it
# has fully downloaded.
ffmpeg -y -loglevel error -i "$RECORDING" \
-vf "tpad=stop_mode=clone:stop_duration=1.2" \
-c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p \
-movflags +faststart -an "$OUTPUT"
# 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
echo "Demo recorded to $OUTPUT"
echo
echo "Embed it with:"
echo "<video src=\"$URL\" controls></video>"
+25
View File
@@ -0,0 +1,25 @@
# 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" }
+33 -26
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,40 @@ 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).~~
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.
The terminal size, font and colours live in `demo/settings.tape`, which the
script sources into the tape it generates for the demo.
### 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 two things with the mp4: it writes it to your assets
worktree, and it uploads a copy to GitHub's attachment store. The script then
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.
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.