跳转到内容

生成动画 SVG

console2svg 生成动画 SVG 时,并不采用将每一帧的完整屏幕保存为图像序列的方式。 而是将录制数据按时序输入终端仿真器,仅将发生变化的行聚合为可复用的定义,并通过 SMIL(Synchronized Multimedia Integration Language:用于在 XML 中描述动画时间与属性变换的 W3C 标准规范)控制各行的显示时间。 通过这种结构,即便是长时间的录制,也能将文件体积和 DOM 元素数量维持在极低水平。

终端录制数据中的事件边界并不直接等同于动画的帧边界。 一次屏幕刷新可能会被拆分为多次写入(PTY 的 read 或 write),而且有些控制序列只会更改内部状态而不会改变屏幕上的可见字符。

为此,在处理完每个事件后,系统会立即通过 ScreenBuffer.GetContentSignature() 计算屏幕内容的签名,并与上一帧进行对比。 该签名不包含光标位置和闪烁状态,因此单纯的光标移动不会增加正文内容的保留帧。

同时,系统还会应用 --fps 参数设置的最大帧率限制。 如果在指定的时间窗口内发生了多次显示更新,系统不会记录全部中间状态,而仅将该区间内最后确定的状态作为待处理帧保留。

快照的保存采用了行级写时复制(Copy-on-Write, CoW)机制。 在创建快照时,未发生变化的行数组引用将与现有缓冲区共享;只有在此后某一行被修改时,才会为该行分配并复制新的数组。 因此,即便以高频率评估帧状态,也能避免因深拷贝整个屏幕带来的内存开销和垃圾回收压力。

即使确定了所有保留帧,console2svg 也绝不会按帧数将整个屏幕重复输出为 SVG 元素。 因为终端界面中的许多行(例如命令提示符行或状态栏)往往会在很长时间内保持完全相同的内容。

渲染器的 PrepareAnimatedRows 方法会扫描所有帧中的全部可见行并构建 行目录。 它利用各行的视觉签名(由字符、颜色和属性计算得出的哈希值)在已有行中检索,当签名匹配时,再进一步比对全部单元格的实际数据以确认完全一致。 在排除哈希碰撞可能导致误复用的风险后,仅将唯一的行写入 SVG 的 <defs> 元素中。

与此同时,在发现唯一行时也会一并收集所需的文字样式(CSS 类)。 这种单趟处理省去了为了收集样式而额外遍历所有帧的开销,将解析与定义收集整合为一次扫描。

在输入命令或时钟秒数跳动等只有一行中的少数字符发生改变的场景下,系统会应用 行差分 机制。 该机制通过 <use> 元素引用前一帧的行定义,并仅在上方叠加绘制发生改变的列范围。

行差分定义示例
<defs>
<!-- 基础行定义 -->
<g id="r1">
<text y="14" fill="#cdd6f4">Building project... [ ]</text>
</g>
<!-- 继承 r1 并仅覆盖进度条 4 个字符的行定义 -->
<g id="r2">
<use href="#r1"/>
<!-- 覆盖修改列(第 21 至 24 列)的背景与文字 -->
<rect x="176.4" y="0" width="33.6" height="18" fill="#11111b"/>
<text x="176.4" y="14" fill="#a6e3a1">====</text>
</g>
</defs>

不过,行差分并非无条件使用。 能够作为差分处理的范围被限制在“不超过 16 列”且“不超过整行宽度的四分之一”以内,同时 <use> 引用的嵌套深度也被限制在 4 层以内。 如果将差分切分得过细,会导致 SVG 渲染器在解析引用时的开销剧增,反而损害渲染性能。

此外,当差分边界恰好落在全角字符的右半部分(延续单元格)时,范围会自动向外扩展,防止字符被截断。 而在指定了手动掩码模式的行中,为了避免掩码目标字符串跨越差分边界导致无法匹配的问题,系统会自动禁用行差分。

在屏幕上显示已目录化的行定义时,系统使用 SMIL 的 <animate> 元素控制其可见状态。 在同一物理行上连续引用同一行定义的多个帧会被合并为一个区间(run),并放置一个对应的 <use> 元素。

显示状态的切换指定了不进行中间值插值的 calcMode="discrete"。 这是因为终端的显示并不会平滑过渡,而是在某一时刻离散地跳变为下一个字符状态。

动画 SVG 行引用结构示例
<svg xmlns="http://www.w3.org/2000/svg" ...>
<defs>
<!-- 目录化后的唯一行定义 -->
<g id="r1">
<text y="14" fill="#cdd6f4">$ git commit -m "update"</text>
</g>
<g id="r2">
<text y="14" fill="#a6e3a1">[main 4f1a2b3] update</text>
</g>
</defs>
<!-- 屏幕第 1 行:随时间由 r1 切换为 r2 -->
<g class="c2s-line" transform="translate(0, 0)">
<!-- 仅在 0.0s 至 2.0s(总长 4s 的前半段 50%)显示的 r1 -->
<use href="#r1">
<animate
attributeName="display"
values="inline;none"
keyTimes="0;0.5"
calcMode="discrete"
dur="4s"
repeatCount="indefinite"
/>
</use>
<!-- 仅在 2.0s 至 4.0s(后半段 50%)显示的 r2 -->
<use href="#r2">
<animate
attributeName="display"
values="none;inline"
keyTimes="0;0.5"
calcMode="discrete"
dur="4s"
repeatCount="indefinite"
/>
</use>
</g>
</svg>

当动画需要循环播放时,会添加 repeatCount="indefinite";不循环时,则通过 fill="freeze" 将结束时的状态固定下来。 视频末尾的淡出效果并非针对单行应用,而是在最外层父分组上对 opacity 施加动画。 此外,终端规范中的文字闪烁(SGR 5)不表现为屏幕状态切换,而是通过独立的 CSS 关键帧动画实现。

光标的位置和显示状态作为与正文行目录完全解耦的独立图层进行渲染。 因为如果每次光标移动或闪烁都要重新生成行定义,行目录的去重机制就会失效。

将光标随时间的变化输出为专用的动画区间,既能保证行正文的缓存效率不受影响,又能平滑还原光标的动态变化。

当通过 --time 指定了录制数据的起始裁剪时间时,系统会自动将该时间点前最近的屏幕状态补充为首帧,以避免裁剪区间起始点出现画面完全空白的现象。

在录制末尾,系统会追加 --sleep 选项指定的等待时间。 即便没有显式指定,也会保留一个最小的显示维持时间,避免在最后的操作结果刚刚显示出来的瞬间就立即跳回循环开头。

像 vim 或 htop 等全屏应用程序,在退出时通常会从备用屏幕切回主屏幕,并清空整屏返回 shell 提示符。 当录制结束时的最后一个事件属于这种屏幕恢复操作且导致内容为空时,系统会排除该恢复事件,将此前最后一个有效的操作画面作为最终帧保留。