動画はフレームと秒数で動く。そのためアニメーションのタイミングは、気を抜くと from: 0, to: 90 のような絶対フレーム指定が自然に見えてしまう。これは静的なウェブ UI の duration: 300ms と同じ問題だが、動画生成の場合はシーンの尺が頻繁に変わるため、破綻がより早く・より広範に到達する。
課題:絶対フレーム指定が破綻する典型パターン
シーン尺が変わるたびに全修正が発生する
バーチャートのカウントアップアニメーションを from: 20, to: 80 と書いた場合、シーン全体が 150 フレームのときは全体の 40〜53% に相当する。問題は「150 フレームという仮定がコードに埋め込まれている」ことだ。
シナリオ変更でシーンが 120 フレームに縮まった場合、from: 20, to: 80 はシーン終盤の 67% まで続くカウントアップになる。全体のテンポが崩れる。to: 80 を to: 65 に直そうとすると、同じシーンに別のアニメーションが 3 つあれば 3 箇所の修正が必要になり、ブロックをまたいで同期しているアニメーションは連鎖的に壊れる。
AI が生成するたびにバラつく
Claude Code にアニメーション実装を指示すると、毎回違う数値が出てくる。t.stagger(0.1, 0.02, ...) と書いたり t.stagger(0.08, 0.015, ...) と書いたりする。どちらが正しいかの基準がコードに存在しないためだ。
レビュアーも判断できない。「この 0.1 は以前の実装と揃っているか?」を確認しようとすると、コードベース全体を検索して類似ブロックを見つけ、値を目視比較する必要がある。AI が速くコードを生成しても、確認コストが掃き出される。
リファクタリングの見落とし
ブロック A の staggerPerItem を 0.03 から 0.02 に変更したとき、ブロック B が同じ値を独立してハードコードしていれば B は変更対象として発見されない。最終動画で A と B のリズムがわずかに違う「なんとなく揃っていない」状態が生まれ、原因特定に時間がかかる典型例だ。
アプローチ:相対タイミング(Duration 比率)で記述
解決策は、タイミングを「シーン Duration に対する比率(0〜1)」として記述することだ。
// ❌ 絶対フレーム
t.lerp(20, 80, [0, 1]) // シーンが 150 フレームという暗黙の前提
// ✅ 比率
t.lerp(TIMING.countUp.start, TIMING.countUp.end, [0, 1])
// TIMING.countUp = { start: 0.15, end: 0.55 }
// シーンが何フレームでも「15〜55% の区間」にアニメーションが収まる
t.lerp の第1・第2引数をシーン Duration の比率にすることで、シーン尺の変更に対してコードが自動的に追従する。同じ比率は stagger でも使える。
// ❌ 絶対値
t.stagger(i, 0.1, 0.02)
// ✅ 定数参照
t.stagger(i, TIMING.dataStart, TIMING.staggerPerItem)
// dataStart: 0.1 = シーン開始 10% 以降
// staggerPerItem: 0.02 = アイテムごとに 2% ずらす
TIMING 集約定数で再利用可能にする
比率で書くだけでは不十分だ。比率の値そのものが散在すると同じ問題が再発する。八雲の montage プロジェクトでは src/config/defaults/animation.ts に TIMING オブジェクトとしてすべてのタイミング定数を集約している。
export const TIMING = {
titleFade: { start: 0, end: 0.12 },
dataStart: 0.1,
staggerPerItem: 0.02,
countUp: { start: 0.15, end: 0.55 },
bar: {
countStart: 0.12,
countEnd: 0.35,
countStagger: 0.02,
},
// ... 全チャート・全ブロックの定数
} as const;
コンポーネントはこのオブジェクトから参照するだけでよい。
import { TIMING } from "../config/defaults";
// タイトルフェード
const titleOp = timing.lerp(TIMING.titleFade.start, TIMING.titleFade.end, [0, 1]);
// バーチャートのカウントアップ
const countProgress = timing.at(TIMING.bar.countStart + i * TIMING.bar.countStagger);
新しいブロックタイプを作るとき、まず TIMING に定数を追加し、それからコンポーネントで参照する。逆順(コンポーネントで決めて後で集約ファイルへ移す)は「後でやる」が永遠にやらないになる。全ブロックの staggerPerItem を変更したい場合は animation.ts の1行を変えるだけで全コンポーネントに反映される。
AI に書かせるときのルール明示
Claude Code にタイミング実装を書かせるとき、指示のなかにルールを書いても揮発する。プロジェクトに残り続けるのは CLAUDE.md と .claude/rules/ 配下のファイルだ。
montage プロジェクトの .claude/rules/coding-standards.md には以下を明記している。
アニメーションタイミング
- ❌
t.lerp(0, 0.12, ...),t.stagger(i, 0.1, 0.02),t.at(0.15)- ✅
import { TIMING } from "../config/defaults"→t.lerp(TIMING.titleFade.start, TIMING.titleFade.end, ...),t.stagger(i, TIMING.dataStart, TIMING.staggerPerItem)
この記述により、Claude Code はセッション開始時にルールを読み込み、TIMING 定数を経由せずにリテラル数値を書こうとすると「コーディング標準に反する」と自己判断して修正する。グローバルの CLAUDE.md には「絶対フレーム禁止:アニメーションは相対タイミング(シーン Duration に対する比率)で記述する」を置き、新規プロジェクトに移っても同じ基準が引き継がれる設計にしている。
加えて、t.stagger(0.1, ...) のように数値リテラルを直接渡している呼び出しを ESLint で検出し、CI で弾く。AI が「うっかり書いた」数値も機械的に発見できる体制だ。
運用してわかった効果と落とし穴
効果。新しいチャートブロックを追加するとき、タイミングの検討が TIMING オブジェクトを眺めるだけで済む。「バーチャートと似たリズムにしたい」なら bar.* を参照すればよく、新しいブロックのタイミングが既存ブロックと自然に揃う。AI にブロックを書かせるときも、TIMING 参照さえ守られていれば出力の品質が一定水準に収まる。
落とし穴。TIMING オブジェクトが大きくなると、名前空間が衝突しやすくなる。countStart という名前がバーチャート・ウォーターフォール・ヒストグラムで重複して混乱したため、bar.countStart・waterfall.countStart のようにチャートタイプ別のネストに整理し直した経緯がある。集約ファイルの設計は「フラット定数の羅列」ではなく「用途別のネスト構造」にしておくことを推奨する。
もう一点。比率設計は「シーン内の相対位置」を保証するが、シーン間のリズム統一は別の問題だ。シーン A が 90 フレーム・シーン B が 300 フレームで同じ TIMING.dataStart: 0.1 を使っても、体感上のテンポは大きく異なる。シーン間のテンポを揃えたいなら、シーン長をあらかじめ規格化する設計を別途検討する必要がある。
まとめ
タイミングのハードコードが引き起こす問題は「変更への脆弱性」と「AI 生成値のバラつき」の2つに集約される。どちらも TIMING 集約定数と CLAUDE.md へのルール明記によって抑制できる。
ルールをコードで書き、CLAUDE.md に書き、ESLint で守る。この三層があれば、AI がどれだけ速くコードを生成しても設計の一貫性は維持される。
config 駆動設計の全体像は ハードコード禁止と config 駆動設計 にまとめているので、あわせて参照してほしい。