diff --git a/demo/config.yml b/demo/config.yml deleted file mode 100644 index defe50a5b..000000000 --- a/demo/config.yml +++ /dev/null @@ -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" diff --git a/demo/record_demo.sh b/demo/record_demo.sh index 97d5c2f36..88b43cbea 100755 --- a/demo/record_demo.sh +++ b/demo/record_demo.sh @@ -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] " - echo "e.g. using full path: $0 gif pkg/integration/tests/demo/nuke_working_tree.go" + echo "Usage: $0 [--no-upload] " + 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,179 @@ 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, so that waiting for the marker cannot match the command that prints it. +# +# The screen is cleared before lazygit is started, so that the screen the +# terminal restores when lazygit exits is a blank one rather than the typed +# command line. The frames vhs records after lazygit has exited are then blank +# apart from the marker, which is what the trimming below looks for. +cat > "$TAPE" < "$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 terminal has been cleared for the marker. Find the first +# of those blank frames so we can cut them off. Measure the share of pixels +# that are not background: a cleared terminal reads around 0.03, while the +# emptiest thing lazygit draws reads around 1.2, an empty panel still being +# drawn with a frame around it. Demos whose config turns borders off would +# need a different signal, as the start marker above would too. +BLANK_INK=0.5 + +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 -v blank="$BLANK_INK" ' + /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 blank frames at the end. Starting from the + # end leaves a blank frame that the demo painted over again where + # it is, so only the tail after lazygit is cut. + k = n + 1 + while (k > 1 && ink[k - 1] < blank) { + k-- + } + if (k <= n) { + 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 "" diff --git a/demo/settings.tape b/demo/settings.tape new file mode 100644 index 000000000..3c98a213b --- /dev/null +++ b/demo/settings.tape @@ -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" +} diff --git a/docs-master/dev/Demo_Recordings.md b/docs-master/dev/Demo_Recordings.md index 1068e688f..67626ec4c 100644 --- a/docs-master/dev/Demo_Recordings.md +++ b/docs-master/dev/Demo_Recordings.md @@ -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] +scripts/record_demo.sh # 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 + ``` -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 `