lazygit 演示录制系统实战:复用集成测试框架录制 GIF/MP4 演示视频
lazygit 的文档和 README 中大量使用 GIF 演示视频来展示各项功能,这些视频并非人工操作截屏,而是由一套"演示录制系统"自动生成。本文基于仓库内 Demo_Recordings.md 文档展开,系统讲解如何借用 lazygit 的集成测试框架编写 demo、设置字幕、通过 assets 分支 worktree 管理产物,以及 terminalizer + gifsicle/ffmpeg 的完整录制流水线。读完后你可以独立为任意新功能录制出可嵌入文档的演示视频。
整体思路:把 demo 当作一种特殊的集成测试
lazygit 之所以能低成本地保持演示视频与 UI 同步更新,关键在于:演示视频直接复用集成测试的录制系统。每个 demo 本质上就是一个普通的集成测试,只是多了一个 IsDemo: true 标记,并额外配置了适合"表演"的界面行为。因此,写 demo 前建议先熟悉集成测试的写法,见 pkg/integration/README.md,其核心结构为两步:
- Setup step(准备阶段):通过
shell在临时仓库中执行 git 命令,构造出想要的初始状态(分支、提交、远端、冲突等); - Run step(运行阶段):由测试驱动
t模拟用户按键、选单、确认弹窗,并做断言。
集成测试通过 justfile 中的 just 配方运行(just e2e 无头跑全套,just e2e-tui 打开测试浏览界面),这些同样是录制 demo 的前置技能。
环境准备
录制链路依赖三个外部工具加一套带图标的字体,原文档给出的安装命令如下:
# 用于录制
npm i -g terminalizer
# 用于 GIF 压缩
npm i -g gifsicle
# 用于 MP4 转码
brew install ffmpeg
# 带图标(nerd 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
各工具在流水线中的分工(可在 demo/record_demo.sh 中逐一印证):
| 工具 | 用途 | 脚本中的位置 |
|---|---|---|
terminalizer |
把终端会话录制成 YAML 描述文件,再渲染成 GIF | L67 录制、L69 渲染 |
gifsicle |
对原始 GIF 压缩调色板,产出可入库的小体积 GIF | L78 |
ffmpeg |
将 GIF 转为 faststart MP4(备用输出格式) | L75 |
| Nerd Font 字体 | 让终端中的 git 图标(分支/提交/文件)能正常显示 | 由 demo/config.yml 的 fontFamily 引用 |
脚本开头会用 command -v 检查 terminalizer 与 gifsicle 是否存在,缺失时打印安装命令并退出(L42-L54),所以直接运行脚本即可得到明确提示。
什么是 Demo:IsDemo 标记及其 UI 效果
所有 demo 存放在 pkg/integration/tests/demo/ 目录下,当前仓库包含 15 个演示文件(如 interactive_rebase.go、nuke_working_tree.go、stage_lines.go 等)以及一个共享配置文件 shared.go。
在测试定义中设置 IsDemo: true 会产生三个"表演模式"效果(见 Demo_Recordings.md "Creating a demo" 一节):
- 底部按键提示行(options map)变安静:底部那一行平时显示当前上下文的快捷键绑定,在 demo 中改为渲染字幕内容,从而可以在画面底部打出解说文字;
- Fetch/Push/Pull 加入人为延迟:模拟真实网络请求,避免画面中远端操作"瞬间完成"显得不真实;
- 右下角的加载指示器(loader)不出现,避免一闪一闪的刷新动画干扰观看。
这三个效果在源码中都有落点,可以逐一验证:
- 标记定义:
IsDemo bool字段位于 test.go 的NewIntegrationTestArgs中,并以IsDemo()方法暴露在测试类型接口 types.go 上; - 效果 1 的实现:options_map.go 中,GUI 渲染底部按键提示前会先判断
func (gui *Gui) renderContextOptionsMap() {
// In demos, we render our own content to this view
if gui.integrationTest != nil && gui.integrationTest.IsDemo() {
return
}
// ...
}
demo 模式下直接跳过按键提示的渲染,把这一行让给字幕;
- 通用的 demo 判断入口是 gui_common.go 中的
InDemo()(内部同样是integrationTest != nil && integrationTest.IsDemo()),GUI 其他需要区分"演示中"与"普通测试"的逻辑(如 loader 行为)可通过它分支。
原文档还特别提醒:demo 的断言不需要像正式测试那么严格,但仍应保留基础断言——这样将来若自动化"批量更新 demo"流程,断言失败能立刻告诉你某个演示已经和当前 UI 脱节。
编写一个 Demo:三步走
写 demo 的流程与写集成测试一致:
- 用
SetupRepo把仓库搭好; - 用沙箱模式(sandbox mode)手动跑一遍,感受需要发生什么;
- 回来把操作序列写成代码。
下面以 interactive_rebase.go 为例拆解完整结构:
var InteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Interactive rebase",
ExtraCmdArgs: []string{"log", "--screen-mode=full"},
Skip: false,
IsDemo: true,
SetupConfig: func(config *config.AppConfig) {
setDefaultDemoConfig(config)
},
SetupRepo: func(shell *Shell) {
shell.CreateRepoHistory()
shell.NewBranch("feature/demo")
shell.CreateNCommitsWithRandomMessages(10)
shell.CloneIntoRemote("origin")
shell.SetBranchUpstream("feature/demo", "origin/feature/demo")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.SetCaptionPrefix("Interactive rebase")
t.Wait(1000)
// ...按键操作序列,中间穿插断言与字幕切换
},
})
几个要点:
SetupRepo:创建带历史的仓库、建feature/demo分支、生成 10 个提交、克隆到origin远端并设置 upstream——一次性把"交互变基 + 强制推送"所需的完整场景搭好,让 Run 阶段专注于"表演";ExtraCmdArgs:以--screen-mode=full全屏模式启动,演示画面更干净;Run阶段的操作序列:聚焦提交列表 → 触发交互变基(keys.Commits.StartInteractiveRebase)→ 范围选中两行(RangeSelectDown用PressFast快速连按)→ 标记 fixup(MarkCommitAsFixup)→ 下移两行 → 删除一项(Remove)→ 下压合并(SquashDown)→ 在"Rebase options"弹窗中选择 continue → 切换字幕前缀为 "Push to remote" → 全屏模式下按Push,并在 "Force push" 确认弹窗中确认;- 断言:通过
t.ExpectPopup().Menu().Title(Contains("Rebase options")).Select(...)等形式,确认预期的弹窗与标题真的出现了,保证录制结果可复现; PressFast与Delay:快速连按用于范围选中等不需要停顿的操作,Delay()则在关键节点给观众留一点阅读时间。
Demo 的共享配置:图标与作者配色
sdemos 共享文件 shared.go 提供了两个助手函数,几乎所有 demo 都会用到:
func setDefaultDemoConfig(config *config.AppConfig) {
// demos look much nicer with icons shown
config.GetUserConfig().Gui.NerdFontsVersion = "3"
}
它把 NerdFontsVersion 设为 "3",让 lazygit 在演示画面中渲染 nerd 图标(配合前面安装的 Nerd Font 字体),这是 demo 观感的重要一环。另一个函数 setGeneratedAuthorColours 为 shell.CreateRepoHistory() 生成的几位"虚拟作者"(Fredrica Greenhill、Oscar Reuenthal 等)指定固定颜色,使提交图的分支配色在每次录制中保持一致、可预期。
添加字幕(captions)
原文档建议为演示添加字幕说明当前正在执行的任务,做法是在 Run 阶段调用 t.SetCaptionPrefix("..."),并像 interactive_rebase.go 中那样在任务切换时再次调用(例如从 "Interactive rebase" 切到 "Push to remote")。其调用链为:test_driver.go 中 TestDriver.SetCaptionPrefix 直接转发给 GUI 的 SetCaptionPrefix,最终内容渲染在 demo 模式下被"让出来"的底部按键行上。写作新 demo 时,直接参考现有 demo 的字幕风格即可。
准备工作产物仓库:assets 分支与 linked worktree
lazygit 把所有静态资源(包括演示视频)存放在一个名为 assets 的独立分支上。这个分支与主分支不共享任何历史,唯一用途就是存放大二进制文件,避免把代码分支的克隆体积撑大。
脚本和 demo 定义在代码分支中,而输出产物在 assets 分支中。因此录制前需要为 assets 分支创建一个 linked worktree:
git worktree add .worktrees/assets assets
这个前置条件被脚本强制校验:demo/record_demo.sh 会用 git worktree list | grep assets 找到 assets worktree 的路径,找不到就打印上面这条 git worktree add 命令并以非零码退出。
worktree 就绪后,脚本把输出目录定为 assets worktree 下的 demo 目录(OUTPUT_DIR="$WORKTREE_PATH/demo",L40)。每次录制会产出三份文件:
- 录制的 YAML 描述文件(terminalizer 的中间产物,
$NAME); - 原始 GIF(
$NAME.gif); - 压缩后的 GIF(
$NAME-compressed.gif)或 MP4($NAME.mp4),取决于你选择的输出格式。
其中 $NAME 取自测试文件路径的 basename 并去掉扩展名(脚本 L59,例如 pkg/integration/tests/demo/interactive_rebase.go → interactive_rebase),所以输出文件名由 demo 文件名天然决定,无需额外指定。
录制命令与底层流水线
确认 demo 跑通后,执行:
scripts/record_demo.sh [gif|mp4] <path>
# 例如:
scripts/record_demo.sh gif pkg/integration/tests/demo/interactive_rebase.go
入口 scripts/record_demo.sh 只是把参数原样转发给 demo/record_demo.sh。后者在参数校验(必须恰好两个参数、TYPE 必须是 gif 或 mp4)之后,依次完成:
# 1. 注册 demo:重新生成测试清单,确保该 demo 可被测试框架发现
go generate pkg/integration/tests/tests.go
# 2. 用 terminalizer 录制终端会话(以 --slow 慢速模式驱动 demo),产出 YAML
terminalizer -c demo/config.yml record --skip-sharing -d \
"go run cmd/integration_test/main.go cli --slow $TEST" "$OUTPUT_DIR/$NAME"
# 3. 将 YAML 渲染成 GIF
terminalizer render "$OUTPUT_DIR/$NAME" -o "$OUTPUT_DIR/$NAME.gif"
# 4a. gif 输出:gifsicle 压缩(256 色调色板 + web 安全色 + -O3)
gifsicle --colors 256 --use-col=web -O3 < "$OUTPUT_DIR/$NAME.gif" > "$OUTPUT_DIR/$NAME-compressed.gif"
# 4b. mp4 输出:ffmpeg 转 faststart MP4
ffmpeg -y -i "$OUTPUT_DIR/$NAME.gif" -movflags faststart -pix_fmt yuv420p \
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" "$OUTPUT_DIR/$NAME.mp4"
几个值得注意的实现细节:
--slow参数:go run cmd/integration_test/main.go cli --slow $TEST以慢速模式运行集成测试 CLI,在按键与鼠标操作之间加入预设延迟,让演示节奏适合人眼观看(与 pkg/integration/README.md 中"watch a test run at a realistic speed"的--slow机制一致);--skip-sharing:terminalizer 默认录制完会上传并返回分享链接,这里跳过该行为,保证产物完全本地化;- gifsicle 压缩参数:
--colors 256 --use-col=web -O3把调色板收敛到 256 色的 web 安全色并做三级优化,这是"原始 GIF 大、入库 GIF 小"的关键步骤,也解释了为何 demo/config.yml 中quality: 100的注释写着"高质量看起来没差别,但过一遍 gifsicle 压缩效果远好得多"; - MP4 的现实约束:原文档用删除线标记了早期"gif 用于 README 首图、mp4 用于其余位置"的分工,并注明结论——README 中无法可靠地存放并链接 mp4,所以目前统一使用 GIF(自动播放、可循环)。脚本仍保留
mp4分支,但实际产物以-compressed.gif为准。
terminalizer 配置:demos 为什么看起来"干净"
录制画面观感由 demo/config.yml 统一控制,关键参数包括:
| 配置 | 值 | 作用 |
|---|---|---|
cols / rows |
120 / 35 |
固定终端尺寸,保证每次录制的画面构图一致 |
repeat |
0 |
GIF 无限循环播放(1 次/-1 次/循环均可配) |
quality |
100 |
原始 GIF 质量拉满,压缩交给 gifsicle |
frameDelay |
auto |
使用真实录制延迟,操作节奏自然 |
maxIdleTime |
2000 |
帧间最大空闲 2 秒,避免画面长时间静止拖长视频 |
frameBox |
type: floating,title: Lazygit |
给画面加一个带 "Lazygit" 标题的浮动窗口框,背景 #1d1d1d |
fontFamily / fontSize |
"DejaVuSansM Nerd Font" / 8 |
使用 Nerd 字体渲染 git 图标;注释特别说明不选 mono 字体是因为图标会显得太小 |
theme |
一组自定义颜色 | 背景透明、前景 #dddad6 等,与整体文档风格统一 |
env |
recording: true |
向录制进程注入环境变量,供程序感知"正在录制" |
这套配置意味着:demo 的视觉规范(尺寸、字体、边框、配色)是集中管理的,UI 改版后只需更新配置与 demo 代码,重跑录制脚本即可批量刷新全部演示视频。
将演示视频嵌入 README 与文档
产物就绪后,发布分两步(assets 与代码分支解耦):
- 在 assets worktree 中,把三份输出文件(YAML、原始 GIF、压缩 GIF)全部暂存,向 assets 分支发起 PR;
- 回到代码分支,在文档中嵌入视频。嵌入路径以 assets 分支为基准,例如原文档给出的示例:
Nuke working tree
由于文档只引用 assets 分支上的固定路径,之后更新视频(比如 UI 改版后重新录制)只需再对 assets 分支发 PR,无需改动任何嵌入该视频的文档——这就是 assets 分支"无共享历史、纯资源存储"设计的直接收益。
小结:完整工作流一览
- 安装
terminalizer、gifsicle、ffmpeg与 Nerd Font 字体; - 在
pkg/integration/tests/demo/下新建 demo 文件,IsDemo: true+SetupConfig(调用setDefaultDemoConfig)+SetupRepo+Run(操作序列 + 基础断言 +SetCaptionPrefix字幕); - 用沙箱模式手动验证一遍,再补全代码;
git worktree add .worktrees/assets assets建立产物 worktree;scripts/record_demo.sh gif pkg/integration/tests/demo/xxx.go,产物落在 assets worktree 的demo/目录;- 在 assets worktree 中提交三份产物并 PR 到 assets 分支;
- 在代码分支的文档中以
demo/xxx-compressed.gif相对路径嵌入。
整套体系把"演示视频"纳入了与集成测试相同的自动化框架:断言保证录制可复现,terminalizer 配置保证画面一致性,assets worktree 保证代码仓库不被大二进制文件污染,三者共同支撑了 lazygit 文档中演示视频可以低成本、批量地跟随 UI 演进持续更新。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00