用 VHS 为 Ruby cli-ui 录制终端演示:从 Tape 脚本到 GIF 的完整实战

原创2026-09-30 14:19:451,457 阅读
文章标签:开发工具

用 VHS 为 Ruby cli-ui 录制终端演示:从 Tape 脚本到 GIF 的完整实战

本指南以 examples/cli-ui/README.md 为核心,系统讲解如何用 VHS 为 Ruby 的 cli-ui 命令行组件库录制高质量终端演示 GIF。你将掌握 VHS .tape 脚本的完整书写方法,包括 IRB 会话编排、隐藏输入、清屏、延时控制,以及如何录制 CLI::UI 的彩色文本、嵌套 Frame、进度条、Spinner、状态组件、符号渲染和交互式提问等典型 UI 场景,并理解其背后的源码级实现原理。

前置准备:依赖与 Gemfile

VHS 是一个把"终端操作脚本"渲染成 GIF/MP4/WebM 的命令行工具,它运行在虚拟终端(由 ttyd 提供,页面渲染依赖 headless Chrome)之上,再通过 ffmpeg 合成输出。运行 tape 文件前,需要确保系统中已安装 ttyd 和 ffmpeg 并加入 PATH(参见 README.md 的安装说明)。

本示例要演示的是 Ruby 的 cli-ui 库,因此还需要一个 Ruby 环境。仓库在 examples/cli-ui/Gemfile 中给出了最简单的依赖声明:

source "https://rubygems.org"
gem 'cli-ui'

本地可按如下方式准备环境:

# 在 examples/cli-ui 目录下安装依赖
bundle install

# 打开一个不自动补全的 IRB 会话(--noautocomplete 可避免补全干扰录制画面)
irb --noautocomplete

cli-ui 的所有组件都要求先执行 CLI::UI::StdoutRouter.enable 来接管标准输出路由,否则组件无法正确渲染。

Tape 脚本的公共骨架

examples/cli-ui 目录下的 7 个演示脚本(format.tape、nested-frames.tape、progress.tape、spinner.tape、status-widget.tape、symbols.tape、text-prompt.tape)共享同一套录制骨架。以 format.tape 为例:

Output examples/cli-ui/format.gif

Set FontSize 32

Set Width 2200
Set Height 400

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type 'puts CLI::UI.fmt "{{red:Red}} {{green:Green}}"'
Sleep .5
Enter
Sleep 5

这套骨架可以拆解为四个阶段:

  1. 输出与画布设置:Output 指定渲染产物路径;Set FontSize 32、Set Width 2200、Set Height 400 设定终端字号与画布尺寸。注意 Set 类设置必须放在 tape 文件顶部(除 TypingSpeed 外),放在非设置命令之后会被忽略(见 README.md 的 Settings 说明)。

  2. 隐藏的启动准备:Hide 让 VHS 暂停捕获帧(ExecuteHide 调用 vhs.PauseRecording(),见 vhs.go),随后在"幕后"依次键入 irb --noautocomplete、require 'cli/ui'、CLI::UI::StdoutRouter.enable 并回车——这些准备动作不会出现在最终成品里。

  3. 清屏并恢复录制:Ctrl+L 清空终端屏幕,Show 恢复帧捕获(ExecuteShow 调用 vhs.ResumeRecording()),从这一刻起才是观众能看到的画面。

  4. 表演与定格:用 Type 输入演示命令,Sleep 控制节奏,最后保留足够时长展示结果。

从源码看,Hide/Show 的底层是录制循环中的一个布尔开关:在 vhs.go 的帧采集循环里,每次到点都会先检查 vhs.recording,为 false 时直接 continue 跳过帧写入。也就是说,Hide 期间虚拟终端上的所有操作都不会产出任何帧,非常适合做环境准备与清理。

运行方式很简单:

vhs examples/cli-ui/format.tape

Format:彩色格式化文本

CLI::UI.fmt 是 cli-ui 的文本格式化入口,用 {{red:...}}、{{green:...}} 这类内联标记渲染颜色。完整演示脚本见 format.tape:

Output examples/cli-ui/format.gif

Set FontSize 32

Set Width 2200
Set Height 400

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type 'puts CLI::UI.fmt "{{red:Red}} {{green:Green}}"'
Sleep .5
Enter
Sleep 5

关键细节:

  • 命令字符串使用了单引号包裹,因为内容本身包含双引号;VHS 的 Type 还支持用反引号转义(如 Type `VAR="Escaped"`,见 README.md 的 Type 章节)。
  • Sleep .5 让"输入命令"这个动作在画面上稍作停顿,增加演示的呼吸感;Enter 执行后 Sleep 5 让红/绿两种颜色有充足的展示时间。
  • 宽高取 2200×400 的横向比例,能完整容纳 IRB 提示符与输出行。

format.gif:IRB 中执行 puts CLI::UI.fmt 输出红绿彩色文本的演示画面

Nested Frames:嵌套框架

CLI::UI::Frame.open 可以在终端中绘制带标题的框架区块,且支持嵌套。完整脚本见 nested-frames.tape:

Output examples/cli-ui/nested-frames.gif

Set FontSize 32

Set Width 2000
Set Height 750

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type "CLI::UI::Frame.open('Frame 1') do"
Enter
Type "  CLI::UI::Frame.open('Frame 2') { puts 'inside frame 2' }"
Enter
Type "  puts 'inside frame 1'"
Enter
Type "end"

Sleep 1

Enter

Sleep 3

这段演示展示了多行代码的输入节奏控制:

  • 四行 Type 逐行输入一个完整的 Ruby block;每行结尾用 Enter 换行(注意此时 block 尚未闭合,IRB 会显示继续输入提示符,这正是演示"框架结构如何在交互式会话中逐步展开"的好素材)。
  • 最后一行 Type "end" 之后,先用 Sleep 1 让观众看清收尾输入,再 Enter 触发 block 求值,随后 Sleep 3 展示两层框架同时渲染的结果。
  • 由于嵌套框架画面较高,这里把 Set Height 提高到 750,确保内外两层 Frame 都在画布内。

nested-frames.gif:IRB 中逐行输入嵌套 Frame block,渲染出两层带标题框架的演示画面

Progress:进度条

CLI::UI::Progress 提供一个基于块的进度条 API。完整脚本见 progress.tape:

Output examples/cli-ui/progress.gif

Set FontSize 32

Set Width 2200
Set Height 400

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type "CLI::UI::Progress.progress { |bar| 100.times { sleep 0.02; bar.tick } }"
Sleep .5
Enter
Sleep 5

这段代码在块内循环 100 次,每次 sleep 0.02(总耗时约 2 秒)并调用 bar.tick 推进一格。Sleep 5 保证进度条完整走完并停留展示最终 100% 状态。progress.gif 中可以看到进度条从 0 到满格的动画过程——这正是用 VHS 录制"随时间变化的 UI"的典型场景:进程类组件不需要额外人工输入,只要用 Sleep 把渲染过程完整留在画面里。

Spinner:旋转加载指示器

CLI::UI::Spinner.spin 用于展示耗时任务的加载状态。完整脚本见 spinner.tape:

Output examples/cli-ui/spinner.gif

Set FontSize 32

Set Width 2200
Set Height 400

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type "CLI::UI::Spinner.spin('Spinning...') { sleep 3 }"
Sleep .5
Enter
Sleep 5

spin 方法接收标题字符串和任务块,块内 sleep 3 模拟 3 秒的耗时任务。录制时 Sleep 5 覆盖了整个 3 秒旋转过程,让观众完整看到 spinner 转圈、任务结束、显示完成状态的全过程。与 Progress 一样,这类"异步等待型"组件是 Sleep + Wait 类命令发挥价值的地方(Wait 命令可等待屏幕出现指定正则内容,默认超时 15s,见 README.md 的 Wait 章节)。

Status Widget:行内状态组件

cli-ui 还支持在 spinner 标题中嵌入行内状态组件 {{@widget/status:...}}。完整脚本见 status-widget.tape:

Output examples/cli-ui/status-widget.gif

Set FontSize 32

Set Width 2200
Set Height 400

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type 'CLI::UI::Spinner.spin("Building: {{@widget/status:1:2:3:4}}") { |spinner| sleep 3 }'
Sleep .5
Enter
Sleep 5

{{@widget/status:1:2:3:4}} 依次定义状态组件在四个阶段各显示什么内容(例如"进行中/成功/失败/跳过"四种形态),spinner 推进时组件会随阶段切换渲染形态。这里同样用单引号包裹含双引号的命令字符串。这类"组件状态枚举"是演示 CLI 构建工具时非常出彩的画面,status-widget.gif 直观呈现了标题行内状态图标的动态变化。

Symbols:符号渲染

CLI::UI.fmt 内置了对符号标记的支持。完整脚本见 symbols.tape:

Output examples/cli-ui/symbols.gif

Set FontSize 32

Set Width 2200
Set Height 400

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type 'puts CLI::UI.fmt "{{*}} {{v}} {{?}} {{x}}"'
Sleep .5
Enter
Sleep 5

{{*}}、{{v}}、{{?}}、{{x}} 分别渲染为 cli-ui 内置的星号、对勾、问号、叉号等终端符号(实际形状取决于当前字体与主题)。这段演示是展示"一行命令、多种符号输出"最轻量的方式,也为后续介绍字体/主题设置(如 examples/settings 中的 Set Theme、Set FontFamily)埋下伏笔——同一符号在不同主题下观感不同,录制前建议先用小尺寸脚本试跑确认观感。

Text Prompt:交互式提问

最后是交互场景:CLI::UI.ask 会向用户提出一个问题并等待输入。完整脚本见 text-prompt.tape,它同时声明了两个输出:

Output examples/cli-ui/text-prompt.ascii
Output examples/cli-ui/text-prompt.gif

Set FontSize 32

Set Width 2200
Set Height 500

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type "CLI::UI.ask('Is CLI UI Awesome?', default: 'It is great!')"
Sleep .5
Enter
Sleep 1
Type "I love it!"
Sleep 1
Enter
Sleep 3

这段脚本演示了"提问 → 展示默认值 → 输入回答 → 提交"的完整交互链路:

  1. Type 输入 ask 调用(带 default: 参数),Enter 触发。
  2. Sleep 1 让"问题已显示、等待输入"的提示画面(? Is CLI UI Awesome? (empty = It is great!) + > 输入行)停留足够时间。
  3. Type "I love it!" 模拟用户输入回答,Sleep 1 后再 Enter 提交。
  4. Sleep 3 展示返回结果 => "I love it!"。

该脚本同时输出 .gif 和 .ascii 两种格式。.ascii 输出是纯文本帧序列(可对照仓库中的 text-prompt.ascii,其中记录了每一帧的 IRB 提示符、问题文本、输入行与返回值),这正是 VHS 集成测试能力的体现:.txt/.ascii 输出可作为 golden file 存入仓库,CI 中比对每次运行结果是否一致(见 README.md 的 Continuous Integration 章节)。

text-prompt.gif:IRB 中 ask 提问、显示默认值、输入 "I love it!" 并返回结果的交互演示画面

延伸:带选项的交互提示

同一个目录下还有一个未被 README 收录的补充示例 interactive-prompt.tape,展示 ask 的选项模式:

Output examples/cli-ui/interactive-prompt.gif

Set FontSize 32

Set Width 2200
Set Height 500

Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show

Type "CLI::UI.ask('What do you use?', options: %w(rails go ruby python))"
Sleep .5
Enter
Sleep 1

Down@.5 3
Sleep 1
Up@.5 2
Sleep 1

Enter

Sleep 3

这段脚本引入了两个新的 VHS 能力:方向键与节拍控制。Down@.5 3 表示以 0.5 秒为间隔按下方向键 3 次,Up@.5 2 同理。这里的 @<time> 是"每个按键之间间隔多久"的节拍参数,适用于所有可重复按键命令(Key[@<time>] [count] 语法,见 README.md 的 Keys 章节)。interactive-prompt.gif 中可以看到光标在 rails → go → ruby → python 选项间上下移动,最终停在选定项上的完整过程。

命令与设置速查(结合源码)

下表汇总了 cli-ui 演示脚本中用到的 VHS 命令及它们在 command.go 中的实现位置:

命令 作用 源码实现
Output <path> 指定输出(GIF/MP4/WebM/PNG 帧序列/ascii) ExecuteOutput,按扩展名分发到 Video.Output 或 Test.Output
Set FontSize N 设置字号并调用 term.fit 重排终端 ExecuteSetFontSize
Set Width/Height N 设置输出画布宽高 ExecuteSetWidth/ExecuteSetHeight,写入 Video.Style
Type "..." 逐字符模拟键入,可带 @time 覆盖打字速度 ExecuteType,按 keymap 命中按键、否则写入 textarea
Enter / Ctrl+X 回车 / 组合键(按住 Ctrl 依次按下其余键) ExecuteKey / ExecuteCtrl
Hide / Show 暂停 / 恢复帧捕获 ExecuteHide/ExecuteShow → PauseRecording/ResumeRecording(vhs.go)
Sleep <dur> 静置等待(支持 0.5、100ms、1s) ExecuteSleep,time.ParseDuration 后 time.Sleep

值得注意的实现细节:

  • Type 的逐字符机制:ExecuteType 对参数中的每个字符,先在 keymap 中查找对应按键(特殊键走 v.Page.Keyboard.Type),普通字符则直接写入页面上的 textarea,每键入一个字符后 Sleep(typingSpeed) 一次——这就是为什么 Type 天然具备"打字动画"效果。
  • 组合键的按下顺序:ExecuteCtrl 先 Press(ControlLeft) 再依次按下参数中的键,最后一个键用 Type 触发(保持其余键按住直到组合结束),因此 Ctrl+L 这类序列才能被终端正确识别为控制字符。
  • 设置必须在顶部:Settings 映射表(command.go)涵盖 FontSize、Width、Height、Theme、TypingSpeed 等全部设置项;除 TypingSpeed 外,设置命令放在非设置/非输出命令之后会被忽略,所以所有演示脚本都把 Output 和 Set 写在开头。

扩展技巧

基于以上 7 个演示,可以进一步组合 VHS 能力产出更专业的成品:

  • 隐藏阶段做环境准备:Hide 期间可执行 bundle install、构建二进制等耗时操作,配合 Show 只展示最终效果(参考 README.md 的 Hide/Show 章节)。
  • 同一脚本多格式输出:像 text-prompt.tape 那样同时声明 .gif 与 .ascii,一份脚本既能出演示物料,又能生成 golden file 用于集成测试(SaveOutput 会将缓冲区逐行写入并以分隔线收尾,见 testing.go)。
  • 用 Require 提前校验依赖:在 tape 顶部用 Require irb、Require bundle 等命令确保 $PATH 中有对应程序,缺失时提前失败(见 ExecuteRequire)。

完整的 7 个演示脚本、生成的 GIF 产物与 Gemfile 均位于 examples/cli-ui 目录,可对照本指南逐一运行验证。

登录后查看全文
vhs