コンテンツにスキップ

アニメーション SVG の生成

console2svg のアニメーション SVG は、フレームごとの画面全体を画像として並べる方式を採りません。 端末エミュレーターへ録画データを時系列順に流し込み、変化した行だけを再利用可能な定義として集約したうえで、SMIL(Synchronized Multimedia Integration Language:XML でアニメーションのタイミングや属性変化を記述する W3C 標準仕様)によって各行の表示時間を制御します。 この構造により、長時間の録画であってもファイルサイズと DOM 要素数を小さく抑えられます。

端末の録画データに含まれるイベント境界は、そのままアニメーションのフレーム境界とは一致しません。 画面上の 1 回の更新が複数の書き込み(PTY の read や write)に分割される場合や、画面上の文字を変えずに内部状態だけを変えるシーケンスが存在するためです。

そこで、各イベントを処理した直後に ScreenBuffer.GetContentSignature() で画面内容のシグネチャを計算し、前回のフレームと比較します。 このシグネチャにはカーソルの位置や点滅状態を含めないため、カーソルが動いただけでは本文の保持フレームを増やしません。

あわせて、--fps による最大フレームレートの制限も適用します。 指定された時間窓の中で複数回の表示更新が発生した場合は、途中の状態をすべて記録するのではなく、その区間の最後に確定した状態だけを保留フレームとして採用します。

スナップショットの保持には、行単位のコピーオンライト(書き込み時複製)を用います。 スナップショット作成時点では変更されていない行の配列参照を既存のバッファと共有し、その後に書き換えが発生した行だけを新しく複製します。 そのため、高頻度でフレームを評価しても、画面全体のディープコピーによるメモリ消費と割り当て負荷を避けられます。

保持対象の全フレームが確定した段階でも、画面全体をフレーム数分だけ SVG 要素として出力することはありません。 ターミナル上の表示は、プロンプト行やステータスバーなど、長時間を同一内容で維持する行が多いためです。

レンダラーの PrepareAnimatedRows は、全フレームの各可視行を走査して 行カタログ を構築します。 各行のビジュアルシグネチャ(文字、色、属性から計算したハッシュ値)を手がかりに既存行を検索し、シグネチャが一致した場合は全セルの実データを比較して同一性を確認します。 ハッシュ値の衝突だけで誤った行定義を再利用しない安全策を講じたうえで、一意な行だけを SVG の <defs> 要素へ書き出します。

同時に、一意な行が見つかった段階で必要な文字スタイル(CSS クラス)も収集します。 全フレームを走査してスタイルだけを拾い集める別工程を設けないことで、パースと定義収集の走査回数を 1 回にまとめています。

コマンドの入力中や時計の秒表示のように、1 行の中の数文字だけが書き換わる場面では 行差分 を適用します。 直前のフレームの行定義を <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 列以下」かつ「行全体の 4 分の 1 以下」に限定し、<use> による参照のネスト深度も 4 段階未満に制限します。 差分を細かく刻みすぎると、SVG レンダラーが参照を解決する際の負荷が高まり、かえって描画性能を損なうためです。

また、全角文字の右半分(継続セル)に差分の境界が重なる場合は、文字が途中で分断されないように対象範囲を外側へ広げて補正します。 なお、手動マスクパターンが指定されている行では、マスク対象の文字列が差分の境界をまたいで検出不能になる事故を防ぐため、行差分の適用を自動的に無効化します。

カタログ化した行定義を画面上に表示する際は、SMIL の <animate> 要素を用いて表示状態を制御します。 同じ物理行で同一の行定義を参照し続ける連続したフレームを 1 つの区間(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 などのフルスクリーンアプリケーションでは、終了時にオルタネートスクリーン(裏画面)からプライマリ画面へ復帰し、画面全体をクリアしてプロンプトへ戻ることがあります。 録画終了時の最後のイベントがこの画面復元に該当し、表示内容が空になってしまう場合は、その復元イベントを除外し、直前の有効な操作画面を最終フレームとして保持します。