アニメーションを比較GIFに録画する
アニメーションの不具合や改善点は、静止画一枚では伝わらず、「言葉で説明しても判断できない」ケースがほとんどです。そこで、同じアニメーションを「変更前」と「変更後」(またはオン/オフ)で、同じ時間軸に沿ってフレーム単位で録画し、横に並べたGIFを作成すれば、一目で判断できます。
ここでの核はただ一つ:時間はこちらで進めるものであり、実際の時計で進むものではないということです。ヘッドレスブラウザは描画が遅く(特にソフトウェアレンダリング時は、1秒に十数フレームしか描画できない場合があります)。実時間で録画すると、毎回停止する位置が異なり、左右のフレームが永遠に一致しません。そのため、録画時はページ内の時間を「40ミリ秒進める」と言ったときだけ進め、その都度スクリーンショットを撮ります。これにより、マシンが遅くても、nフレーム目は常にn×40ミリ秒になり、2つのバリアントのフレームが一致し、同じバリアントを2度録画しても全く同じになります(実測では75フレームすべてがバイト単位で一致しました)。
1. 最初に考えておくこと:GIFは人に見せるもの、数値は検証用
GIFは、承認、比較、説明資料への掲載に適していますが、問題を見つけるのには向いていません。実際にあったケース:手をゆっくり上げているとき、膝が18°曲がったまま1秒間停止していることが、フレームごとの角度記録で判明しましたが、GIFからは分かりませんでした。そのため:
- まずフレームごとに重要な数値(角度、位置、高さ、透明度、足が滑っていないか)を記録し、オン/オフそれぞれで確認します。
- 数値が合っていれば、GIFを録画して人に判断してもらいます。
数値の記録方法は references/hook.md を参照(ページ内に録画関数を記述し、フレーム画像と同時に各フレームの数値を返すようにします)。
2. クロックの選択
| クロック | いつ使うか | ページの修正が必要か |
|---|---|---|
shim(デフォルト) | ほぼすべての場合:CSSトランジション、CSSアニメーション、element.animate()、rAFを使ったJSアニメーション、Canvas、three.js、PixiJS、Live2D | 不要。clock-shim.js をページスクリプトの前に注入し、performance.now、Date.now、requestAnimationFrame、setTimeout/setInterval を乗っ取り、Math.random は固定シードを使用。CSS / WAAPIアニメーションは各フレームで停止し、同じ時刻に合わせます |
hook | 録画しながらモデルから数値を読み取りたい場合(フレームごとの数値)、またはアプリケーションに固定ステップで進める独自のループがあり、ブラウザのスクリーンショットを経由せずに録画したい場合 | ページ上に window.__capture(...) を記述する必要があります。詳細は references/hook.md |
使用しないでください:Chrome標準の仮想時間(Emulation.setVirtualTimePolicy)— 試した結果、rAFで取得される時間と performance.now が一致せず(カウンターが負の値になる)、アニメーションも指定したステップ通りに進みませんでした。
仮想クロックが管理できないもの:Web Worker内のアニメーション、OffscreenCanvas のワーカー内描画、<video>、new Date()(Date.now のみ乗っ取り対象)、crypto.getRandomValues。これらの場合は hook を使うか、テストページ内で置き換えてください。
3. 手順
- ページとバリアントを準備する。 バリアントはURLパラメータで切り替えるのが最適です(
?v=off、?v=on)。ページはパラメータを読み取ってオン/オフを切り替えます。または--setupを使って録画開始前にJSを実行して切り替えます。ローカルのみで使用するもの(他人が作成したモデル、テストページ)はgit管理外に置き、使い終わったら削除します。 - ページを起動する。 ローカルファイルは
file:///…を直接使用します。バンドルが必要な場合(Viteなど)は開発サーバーを起動し、空いているポートを選びます(同じマシン上の他のセッションがテストポートを使用している可能性があるため、他のセッションのウィンドウは操作しないでください)。Viteは新しいページを初めて開くときに依存関係を最適化し、その後自動でリフレッシュします — 録画中にページがリフレッシュされると「window.xxx is undefined」エラーが発生します。事前にブラウザで一度開いてウォームアップするか、エラーが発生したらもう一度実行してください。 - 録画する。
node <skill>/scripts/record.mjs --out frames --fps 25 --seconds 3 --clip "#stage" \ --ready "window.model != null" --setup "window.model.motion('TapBody', 0)" \ --variant "オフ=http://127.0.0.1:5173/lab.html?v=off" --variant "オン=http://127.0.0.1:5173/lab.html"--clipはその領域のみをキャプチャします(CSSセレクター)。指定しない場合はウィンドウ全体をキャプチャします(--width、--heightでサイズ指定)。--ready:モデルやテクスチャは非同期で読み込まれるため、「読み込み完了」を示す式を指定します。待機中は仮想クロックは進みません。仮に進むと、読み込み速度が毎回異なり、録画開始時刻や乱数の使用位置が変わり、2回の録画でフレームが一致しなくなります(実測済み)。setTimeoutに依存して読み込まれるページの場合は--ready-advance 1を追加します。--wait:読み込み完了後、エントリーアニメーションやスプリングの停止を待つために、仮想時間を何ミリ秒進めるかを指定します。その後に--setupが実行され、実行後すぐに録画が開始されます。--setup内ではawaitを使用できますが、戻り値は破棄されます(three.jsのオブジェクトが大きすぎてCDPで返せないため)。- スクリプトは自動でヘッドレスChrome(専用の設定ディレクトリ)を起動し、録画完了後に終了します。プロセスを残さないでください。
- デフォルトではGPUレンダリングを使用します(
--render gpu)。起動時に「WebGL レンダラー: …」と表示されます。SwiftShaderと表示された場合はGPUが使用されておらず、CPUが多数のコアを占有します。three.js / VRMを録画する際、ユーザーはChromeが CPU を常に占有しているのを目にすることになります。GPUがないマシン、または以前にソフトウェアレンダリングで録画したフレームとピクセル単位で比較する場合は、--render cpuを追加します。 - GPUレンダリングとソフトウェアレンダリングでは描画されるエッジが異なるため、ピクセル単位の比較は同じレンダリング方式内でのみ行います。
- まず比較画像を確認し、GIFを出力する。
python <skill>/scripts/make_gif.py frames --variants オフ,オン --fps 25 --width 300 --title "Hiyori · なでる" --sheet sheet.png --out compare.gif--sheetで数フレーム(デフォルトでは均等に4フレーム、--pick 0,10,20で指定)を1枚のPNGに並べます:自分でこの画像を読み、動きが出ているか、左右が一致しているか、フレームが切れていないかを確認してから、GIFをユーザーに渡します。GIF内の全フレームは共通のパレットを使用するため、フレームごとに色がちらつくことはありません。ラベルは画像上部の白いバーに表示され、コンテンツに重なりません。 - ユーザーに渡す:GIFはファイル送信可能なツールで送ります(代表的なものを数枚送り、残りはどのフォルダにあるかを伝えます)。各GIFの左側と右側がそれぞれ何か、どのバージョンを録画したものか、検証していない点は何か(例:「ソフトウェアレンダリングを使用しているため、エッジがGPUレンダリングよりやや太い」)を明確に説明します。
4. パラメータの決め方
- 25フレーム/秒、1枚あたり2〜4秒。1コマあたり300ピクセル幅。2コマ並べた場合のサイズは約0.6〜3MB(キャラクターやグラデーションが多いと大きくなります)。
- 動作の長さは動作自体に合わせます:
hookでは、クリップの長さに少し余白(停止後の余勢も録画)を加えて使用します。 - フレーミングはバウンディングボックスに合わせる:3Dキャラクターの場合は
Box3.setFromObject(scene)で頭頂まで計算します。頭蓋骨のみを基準にすると、大きなヘルメットやアホ毛が切り取られる可能性があります(実測済み)。足元にも少し余白を残します。 - 各バリアントは、比較する箇所以外はすべて同一にします:同じモデル、同じカメラ、同じ動作、同じ待機時間。ランダムな部分は仮想クロックが固定シードで管理しますが、異なるバリアントで
Math.randomの呼び出し回数が変わると、それ以降のシーケンスがずれる可能性があるため、注意が必要です。
5. 各種ページの対応方法
- Webページ(CSS、WAAPI、rAF):ページの修正は不要です。インタラクション(メニューを開く、ホバー)を録画する場合は、
--setup内でトリガーします(class追加、dispatchEvent)。実測:rAFカウンター、50ミリ秒後に発動するCSSトランジション、CSSキーフレームアニメーション、element.animate()は仮想クロック下で正確に動作し、2つのイージングカーブのバリアントのフレームが一致しました。 - PixiJS / Live2D:ページの修正は不要です。PixiのtickerはrAFと
performance.nowを使用しており、仮想クロックに追従します。物理計算もこの時間に基づいて行われます。Live2DのCubismランタイム(live2dcubismcore.min.js)は、モジュールの前に通常の<script>で読み込む必要があります。エンジンがインポート時にこれをチェックし、遅れると「Could not find Cubism runtime」エラーが即座に発生します。実測:Hiyoriの物理オン・オフの2バリアントを2回録画し、75フレームすべてがバイト単位で一致しました。 - three.js / VRM:ページの修正は不要です。独自のrAFループは仮想クロックに追従します(実測:VRMデスクペットページをそのまま録画でき、オン/オフの違いがすべて反映されました)。フレームごとにボーン角度を読み取りたい場合は、
hookを使用します。詳細はreferences/hook.mdを参照。 - ゲーム、その他のCanvasアプリケーション:上記と同様です。アプリケーションが
setTimeoutでゲームループを駆動している場合も、仮想クロックの管理下にあります。
6. ライセンスとプライバシー
- 他人が作成したモデルや素材から録画したGIFは、そのライセンスに従う必要があります。再配布禁止のもの(例:VN3の一部の条項)は、自分で見る、または効果を人に見せる目的にのみ使用し、公開リポジトリや公開配布はしないでください。使用は許可されているがクレジット表記が必要なものは、公開時に作者名を記載します。
- GIFとフレームはgit管理外の場所に置き(プロジェクト内の専用ローカルフォルダ、または一時ディレクトリ)、使用後はテストページを削除します。
7. 他のスキルとの関係
- 静的インターフェースのスクリーンショット、マルチビューポート、ダークモード:
frontend-verification(そのshot.mjsは実時間のスクリーンショット。アニメーションを録画する場合はこちらを使用)。 - 観客向けのプロモーションビデオ、BGM付きMP4:
motion-showreel。 - キャラクターアニメーション自体を自然にする方法(レイヤー、物理、モーションクリップ):
live2d-vrm-animation。
8. 出典とメンテナンス
- 出典:Cirra(デスクトップペット、RedCarpG/cirra-deskpet)にて、2026-11-10に3Dキャラクターの動きレイヤー、腰と脚、髪の振り回しを実装した際、ユーザーにオン/オフの比較を見せるために開発されました。当時の実装はそのリポジトリの
docs/vrm-motion.md§11–§12、docs/vrm-spring-force.md§8(「検証方法」)に記録されています。経験はプライベートリポジトリのlessons/animation-capture.mdにまとめられています(3Dキャラクター部分はlessons/threejs-vrm-animation.mdにも記述あり)。 - ソース:RedCarpG/redcarpg-skills の
skills/animation-capture/。~/.claude/skills/にあるものはインストールされたコピーであり、直接編集しないでください。 - 変更方法:
- redcarpg-skills リポジトリ内で変更し、PRを出します。
manifest.jsonのバージョンを1つ上げ、node scripts/validate.mjsをパスした後、リリース(taganimation-capture-v<バージョン>)し、ローカルコピーを再インストールします。 - スクリプトを変更した場合は、CSSページ、Live2Dまたはthree.jsページでそれぞれ1回ずつ録画し、2つのバリアントのフレームが一致することを確認します。同じバリアントを2回録画し、フレームが完全に一致することを確認します(Pillowの
ImageChops.differenceを使用)。 - 方法や注意点を変更した場合は、lessons ファイルも同期して更新します。テストケースは
evals/にあります。 - 以下の更新記録に1行追加します。
- redcarpg-skills リポジトリ内で変更し、PRを出します。
- 更新記録:
- 2026-11-12:1.0.0、redcarpg-skills に移行し公開。ローカルの絶対パスを修正、内容は変更なし。
- 2026-11-10、Cirra:初版。
record.mjs(shim、hookの2種類のクロック)、clock-shim.js、make_gif.py。Webページ(CSSトランジション、CSSアニメーション、WAAPI、rAF)、Live2D(Hiyori、物理オン/オフ)、three.js VRM(動きレイヤーオン/オフ)で実測済み。 - 2026-11-10、Cirra:テスト中に、ページ読み込み時に開始したCSS/WAAPIアニメーションが、仮想クロックに捕捉される前に実時間で数ミリ秒進行し、2つのバリアントで16.6msの差が生じることを発見。タイマーでトリガーされるトランジションは、このステップが完了するまで捕捉されない(50msが80msになる)。修正:最初に表示されたアニメーションは0から計算し、タイマー、rAF、および各ステップの開始時に毎回確認するように変更。修正後実測:50msでトリガーされる600msのトランジションは80ms時点で正確に8.0px、2つのバリアントの0フレーム目が一致。2つのテストケース(Webイージング比較、ボールの押しつぶし変形)はスキルあり/なしの両方で合格:この種のタスクはモデル本来でも実行できるが、スキルを使用することで毎回録画スクリプトを書き直す手間と、上記のような既に踏んだ罠を回避できる。
- 2026-11-10、Cirra:髪の振り回し(遠心力、引きずられ)の比較GIFを録画、Yumeka、千駄ヶ谷篠。3Dキャラクターはターンテーブル(キャラクターを回転可能なGroupに入れる)と仮想クロック内の
setIntervalで駆動、ページ内に録画コードを記述せず。 - 2026-11-10:新しい記述ルールに従い、「メンテナンス」セクションを「出典とメンテナンス」に変更。
- 2026-11-10、Cirra:ユーザーが3D録画時にChromeが常に高いCPUを占有することに気づく。
record.mjsをデフォルトでGPUレンダリング(--render gpu;--render cpuはSwiftShader)を使用するよう変更。起動時に使用するレンダラーを表示。Chrome終了前に設定ディレクトリを削除しようとするとEPERMが発生するため、削除できない場合は無視してエラーにしない。実測:RTX 3060 Ti で1フレームあたりのシェーダー負荷が154ミリ秒から8ミリ秒に低下。GPUレンダリングで2回録画、10フレームがバイト単位で一致。GPUとソフトウェアレンダリングではフレームが異なる(エッジ部分)ため、混在して比較しないこと。