R RenderComp
remotion ai-agent typescript animation debugging

AIエージェントが生成したRemotionコードの失敗モードカタログ

執筆: RenderComp チーム 編集方針

Remotionの設計思想は明快だ。動画とは「フレーム番号 → ピクセル列」の純粋関数であり、同じフレームを何度レンダリングしても結果は同一でなければならない。この決定論的な計算モデルは、AIエージェントによる動画自動生成との相性が一見良さそうに見える。エージェントにコンポーネントを書かせ、パイプラインでレンダリングするだけだ。

しかし実際に試すと、生成されたコードは微妙に壊れていることが多い。opacity が2.0になって何も見えなくなる。スプリングアニメーションがコンポジション開始時にすでに終わっている。24fps用のテンプレートを30fpsで使ったら文字が画面外に飛び出す。これらは「ランダムな幻覚」ではない。特定のAPIの意味論をLLMが体系的に誤解することで生じる、予測可能な失敗パターンだ。

このカタログは7つの失敗モードを実際のコードで示し、それぞれの根本原因と修正パターンを解説する。AIエージェントへのシステムプロンプトにチェックリストとして添付すれば、生成コードの品質向上につながる。


失敗モード 1: <Sequence> 内でのフレームをグローバルとして扱う

これが最も頻発する失敗だ。AIエージェントは useCurrentFrame() を「グローバルなフレームカウンター」と誤解し、propsとしてコンポーネントツリーに渡そうとする。

// ❌ AIエージェントが生成するパターン
export const IntroCard: React.FC = () => {
  const frame = useCurrentFrame(); // グローバルフレーム (例: 0–299)

  return (
    <Sequence from={60} durationInFrames={120}>
      {/* frame を子に props で渡している */}
      <InnerContent globalFrame={frame} />
    </Sequence>
  );
};

const InnerContent: React.FC<{ globalFrame: number }> = ({ globalFrame }) => {
  // globalFrame=60 のとき opacity=0 を期待しているが、
  // このコンポーネントはグローバルフレーム60でちょうど描画開始する。
  // つまり「入場アニメーションが0フレーム目に既に始まっている」
  const opacity = interpolate(globalFrame, [60, 90], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  return <div style={{ opacity }}>Hello</div>;
};

Remotionの<Sequence>は内部的にReact Contextを使い、その中で呼ばれるuseCurrentFrame()が返す値をfromプロップの値でオフセットする。グローバルフレームが60のとき、Sequence内のuseCurrentFrame()は0を返す。propsで渡した数値にはこのオフセットが適用されない。

// ✅ Sequence内で useCurrentFrame() を呼ぶ
export const IntroCard: React.FC = () => {
  return (
    <Sequence from={60} durationInFrames={120}>
      <InnerContent />
    </Sequence>
  );
};

const InnerContent: React.FC = () => {
  // ここでは 0 から始まる相対フレームが返る
  const frame = useCurrentFrame();
  const opacity = interpolate(frame, [0, 30], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  return <div style={{ opacity }}>Hello</div>;
};

失敗モード 2: interpolate() の外挿クランプ漏れ

interpolate() のデフォルト外挿モードは 'extend'(線形外挿)であり、'clamp' ではない。入力値が inputRange の外に出ると、出力値も線形に外挿され続ける。

// ❌ クランプなし — frame=60 のとき opacity=2.0
const opacity = interpolate(frame, [0, 30], [0, 1]);

// frame=0  → opacity=0.0  ✓
// frame=15 → opacity=0.5  ✓
// frame=30 → opacity=1.0  ✓
// frame=60 → opacity=2.0  ← CSSに渡すと無効値 / scale等では視覚バグ
// frame=-5 → opacity=-0.167 ← フェードアウト前に既に負値

opacity をブラウザに渡すと値が1.0でクランプされるため見た目に出ないことがある。しかし同じパターンを transform: scale(${value})rgb(${r}, ${g}, ${b}) に適用すると、スケールが負になったりカラー値が不正になったりして描画が壊れる。

// ✅ 常にクランプオプションを明示する
const opacity = interpolate(frame, [0, 30], [0, 1], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});

// キーフレームアニメーションの場合も同様
const x = interpolate(
  frame,
  [0, 15, 30, 45],       // 入力レンジ(昇順必須)
  [0, 100, 80, 100],     // 出力レンジ(バウンス動作)
  { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
);

AIエージェントへのプロンプトに「interpolate() を使うときは必ず extrapolateLeft: 'clamp', extrapolateRight: 'clamp' を明示すること」を制約として加えると効果的だ。


失敗モード 3: spring() へのグローバルフレーム渡し

spring()frame 引数は「アニメーション開始から何フレーム経過したか」を意味する。グローバルフレームをそのまま渡すと、スプリングはコンポジション開始時点から物理演算を始めてしまい、実際に表示したいタイミングにはすでに収束している。

// ❌ Sequence を使わずにフレームをオフセットしていないパターン
const APPEAR_AT = 90; // フレーム90 (3秒@30fps) でアニメーションを開始したい

export const BounceIn: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // frame=90 のとき spring() への入力は 90。
  // 30fps では90フレーム = 3秒間バネが動き続けた後の値が返る → 収束済み
  const scale = spring({
    frame,
    fps,
    config: { stiffness: 200, damping: 15 },
  });

  return <div style={{ transform: `scale(${scale})` }}>...</div>;
};

修正方法は2つある。<Sequence> を使う方法と、フレームを手動でオフセットする方法だ。

// ✅ 方法1: Sequence を使う(推奨)
// Sequence 内の useCurrentFrame() が自動的に 0 から始まる
export const BounceIn: React.FC = () => {
  const frame = useCurrentFrame(); // ここでは 0 から始まる相対フレーム
  const { fps } = useVideoConfig();

  const scale = spring({
    frame,
    fps,
    config: { stiffness: 200, damping: 15 },
  });

  return <div style={{ transform: `scale(${scale})` }}>...</div>;
};

// 親コンポーネント
export const MyComp: React.FC = () => (
  <Sequence from={90}>
    <BounceIn />
  </Sequence>
);

// ✅ 方法2: Sequence を使わない場合は手動オフセット
export const BounceInManual: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // frame - 90 で「開始からの経過フレーム」に変換
  // Math.max(0, ...) で出現前のフレームを 0 として扱う
  const animFrame = Math.max(0, frame - 90);

  const scale = spring({
    frame: animFrame,
    fps,
    config: { stiffness: 200, damping: 15 },
  });

  return <div style={{ transform: `scale(${scale})` }}>...</div>;
};

失敗モード 4: FPS のハードコーディング

AIエージェントはフレーム数を「30fps前提の定数」としてハードコードしがちだ。

// ❌ FPS を固定値で計算
const FADE_START = 270; // 「9秒後」のつもり — 30fps前提
const FADE_END   = 300; // 「10秒後」のつもり

const opacity = interpolate(frame, [FADE_START, FADE_END], [1, 0], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});
// 24fps のコンポジションで使うと FADE_START は 11.25秒、FADE_END は 12.5秒になる

コンポジションが24fpsや60fpsで設定されていると、アニメーションのタイミングがすべてずれる。useVideoConfig() から fps を取得し、秒数で計算するのが正しいパターンだ。

// ✅ useVideoConfig() から fps を取得して計算
export const FadingTitle: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  // 秒 × fps でフレーム数を計算
  const HOLD_SECS = 8;
  const FADE_SECS = 1;

  const fadeStart = HOLD_SECS * fps;               // 8秒後
  const fadeEnd   = (HOLD_SECS + FADE_SECS) * fps; // 9秒後

  const opacity = interpolate(frame, [fadeStart, fadeEnd], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return <div style={{ opacity }}>Title</div>;
};

コンポジション登録側も同様に定数を一元管理する。

// ✅ composition-config.ts
export const VIDEO_CONFIG = {
  fps: 30,
  durationSecs: 10,
  width: 1920,
  height: 1080,
} as const;

export const VIDEO_FRAMES = VIDEO_CONFIG.fps * VIDEO_CONFIG.durationSecs;

失敗モード 5: durationInFrames のオフバイワン

フレームは0インデックスだ。durationInFrames={300} のコンポジションは、フレーム0からフレーム299まで存在する。フレーム300は存在しない。AIエージェントはこの境界を1フレームずらして計算することが多い。

// ❌ 最終フレームを durationInFrames と同じ値にしている
const { durationInFrames } = useVideoConfig(); // 例: 300

// frame=299 (最終フレーム) のとき:
// interpolate(299, [0, 300], [1, 0]) → 0.003... ≈ 0(ほぼ消えるがゼロではない)
const opacity = interpolate(frame, [0, durationInFrames], [1, 0], {
  extrapolateRight: 'clamp',
});
// ✅ 最終フレームは durationInFrames - 1
const { durationInFrames } = useVideoConfig();

const opacity = interpolate(frame, [0, durationInFrames - 1], [1, 0], {
  extrapolateLeft: 'clamp',
  extrapolateRight: 'clamp',
});
// frame=299 のとき opacity=0.0 ← 完全に透明

<Sequence durationInFrames={60}> の内部でも同じだ。有効なフレーム範囲は0から59であり、フレーム60を目標とするアニメーションはSequenceの最終フレームで未完了になる。


失敗モード 6: spring() の物理パラメータの誤解

spring()config オブジェクトはバネの物理パラメータを直接操作する。AIエージェントはこれを「速さ調整のノブ」として直感的に扱い、stiffness: 1damping: 1 のような極端な値を生成することがある。

// ❌ 意味を理解しないパラメータ設定
const scale = spring({
  frame,
  fps,
  config: {
    stiffness: 1,  // 剛性が極めて低い → 何十秒もかかる
    damping: 1,    // 減衰がほぼない → 永遠に振動し続ける
  },
});

以下に代表的な設定と挙動を示す。

// 参考: stiffness と damping の組み合わせによる挙動(30fps基準)
// { stiffness: 300, damping: 40 }  → キレのある高速アニメーション。約15フレームで収束
// { stiffness: 100, damping: 10 }  → Remotionのデフォルト。自然なバウンスあり
// { stiffness: 50,  damping: 30 }  → ゆっくりとした重い動き。約60フレームで収束
// { stiffness: 200, damping: 300 } → バウンスなし、スライドインに近い挙動

また、spring() は有限フレームで数学的に収束しない。出力は to 値に漸近するが、厳密にはそこに到達しない。

// spring() の出力を厳密な条件分岐に使わない
const isVisible = spring({ frame, fps }) === 1.0; // 常に false

// ✅ 閾値比較を使う
const progress = spring({ frame, fps, config: { stiffness: 200, damping: 20 } });
const isSettled = Math.abs(progress - 1.0) < 0.001;

有限フレームで収束を確定させたい場合は durationInFrames オプションを使う。Remotionはバネの軌道を内部でスケーリングし、指定フレームで to 値に到達させる。

const scale = spring({
  frame,
  fps,
  config: { stiffness: 200, damping: 15 },
  durationInFrames: 24, // 24フレームで完全に収束(0.8秒@30fps)
});

失敗モード 7: Sequence の外側に重い処理を置く

<Sequence> はその表示範囲外でReactの子要素レンダリングを抑制するが、Sequence外部のコードはすべてのフレームで実行される。AIエージェントはデータ準備や重い計算をコンポーネントのルートに置きがちだ。

// ❌ Sequence 外での重い処理
export const LongVideo: React.FC = () => {
  const frame = useCurrentFrame();

  // この計算は全フレーム(0〜299)で毎回実行される
  // 動画の後半200フレームでは不要なのに
  const parsedData = heavyDataParsing(someJSON); // 重い処理

  return (
    <>
      <Sequence from={0} durationInFrames={100}>
        <IntroSection data={parsedData} /> {/* 前半100フレームのみ使用 */}
      </Sequence>
      <Sequence from={100} durationInFrames={200}>
        <BodySection /> {/* parsedData 不使用 */}
      </Sequence>
    </>
  );
};

Remotionのレンダリングは各フレームを並列処理する。重い計算がSequence外にあると、その計算量は全フレーム数倍に膨らむ。

// ✅ 計算を必要なコンポーネントの内部に移動
export const LongVideo: React.FC = () => {
  return (
    <>
      <Sequence from={0} durationInFrames={100}>
        <IntroSection /> {/* 内部で必要なデータを処理 */}
      </Sequence>
      <Sequence from={100} durationInFrames={200}>
        <BodySection />
      </Sequence>
    </>
  );
};

const IntroSection: React.FC = () => {
  // この処理はフレーム0〜99のみ実行される
  const parsedData = heavyDataParsing(someJSON);
  const frame = useCurrentFrame();
  // ...
};

防御パターン:バリデーションヘルパー

AIエージェントと協働するワークフローでは、上記の失敗モードをコードレベルで防ぐヘルパーを整備しておくと安定性が増す。

// utils/animation-guards.ts
import { interpolate, spring } from 'remotion';

/**
 * クランプオプションを強制する interpolate ラッパー。
 * AI生成コードの外挿漏れを防ぐ。
 */
export const clampedInterpolate = (
  frame: number,
  inputRange: [number, number] | [number, number, number, number],
  outputRange: number[],
): number => {
  return interpolate(frame, inputRange, outputRange, {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
};

/**
 * フレームオフセットを明示的に要求する spring ラッパー。
 * グローバルフレームをそのまま渡すパターンを構造的に排除する。
 */
export const offsetSpring = ({
  frame,
  startFrame,
  fps,
  config,
  from = 0,
  to = 1,
  durationInFrames,
}: {
  frame: number;
  startFrame: number;
  fps: number;
  config?: Parameters<typeof spring>[0]['config'];
  from?: number;
  to?: number;
  durationInFrames?: number;
}): number => {
  return spring({
    frame: Math.max(0, frame - startFrame),
    fps,
    config,
    from,
    to,
    durationInFrames,
  });
};

これらのヘルパーをプロジェクトに用意した上で、AIエージェントへのシステムプロンプトに「生の interpolate()spring() を直接使わず、clampedInterpolate()offsetSpring() を使うこと」と明記する。


まとめ

AIエージェントが生成するRemotionコードの失敗の大半は、2つの根本的な誤解から発生する。

ひとつはフレームの相対性だ。useCurrentFrame()<Sequence> の内部でオフセットされた相対値を返す。propsで渡したグローバルフレームにはこの仕組みが適用されない。spring()frame 引数も「開始からの経過フレーム」であり、グローバルフレームをそのまま渡してはならない。

もうひとつはデフォルト値の方向性だ。interpolate() のデフォルト外挿は 'extend'(線形外挿)であり、想定外の値域でコードが動いたとき、値が静かに発散する。'clamp' は常に明示すべきだ。

加えて、FPSハードコーディングとオフバイワンエラーは機械的なチェックで検出できる失敗だ。AIエージェントへのコードレビュープロセスで「durationInFrames と同値のフレームを参照している箇所」「fps なしの数値リテラルをフレーム数として使っている箇所」を静的に検出するだけで、生成コードの品質は向上する。

RenderComp カタログのテンプレートが採用しているアニメーション設計パターンも、これらの原則に基づいて構成されている。エージェントに新しいコンポーネントを生成させる前に、既存のテンプレートコードを少数ショット例示として渡すアプローチも、失敗モードを減らす実践的な手法だ。

販売中

1,000以上のRemotionテンプレートを一括入手

買い切り(一括払い)・サブスクなし・生涯アップデート無料。TypeScript製。

料金プランを見る →