AIエージェントが選べるRemotionライブラリの設計
執筆: RenderComp チーム 編集方針
Remotionの設計思想は一文で言い表せます。動画フレームは、Propsから決定論的に計算される純粋な関数の出力です。同じPropsを渡せば、何度レンダリングしても100フレーム目の画素は変わりません。この性質がRemotionをプログラマブルなビデオ制作に適したツールにしており、AIエージェントとの親和性も同じ原理から来ています。
LLMベースのエージェントがビデオ生成タスクを受けると、「どのコンポーネントを使うか」「各Propsに何を渡すか」「どう組み合わせるか」という三つの判断をコードとして出力します。エージェントはコンポーネントのPropsに値を渡すだけで、タイミング・アニメーション・合成を制御できます。この判断の精度を左右するのが、コンポーネントAPIの設計品質です。
人間の開発者であれば、IDEのオートコンプリートやソースコードを読んでPropsの意図を把握できます。エージェントが持つ手がかりは型定義とJSDocコメントのみです。コンポーネントライブラリがエージェントに対して「発見可能」かどうかは、この二点だけで決まります。
エージェントが踏む落とし穴
エージェントに汎用的なRemotionコードを生成させると、特定のパターンで繰り返し失敗します。最も多いのはタイミングのハードコードです。
// エージェントが生成しがちなコード
const MyText: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
// fpsを30と決め打ちしている
const opacity = Math.min(frame / 30, 1);
return <div style={{ opacity }}>{text}</div>;
};
このコンポーネントは30fpsの合成では正しく動きますが、60fpsの環境に組み込むとフェードインが2倍の時間をかけます。エージェントがuseVideoConfigを知らなかったわけではなく、コンポーネントのPropsにfps情報が含まれておらず、内部でfpsを取得すべきことが型から読み取れなかったのです。
もうひとつ頻出するのが、値域の誤解です。delay?: numberというPropsがあるとき、それがフレーム数なのかミリ秒なのかを型情報だけでは判断できません。エージェントは文脈から推測しますが、推測は外れます。
エージェント向けProps設計
エージェントが確実に使えるProps契約を設計するには、タイミングの正規化・値域の明示・不正な組み合わせの型レベルでの排除という三点を意識します。
フレーム数やfpsをPropsに直接持たせず、コンポーネント内部でuseVideoConfigから取得する設計にすると、エージェントはfpsを意識しなくてよくなります。TypeScriptの型システムは数値の範囲を表現できないため、JSDocの@rangeアノテーションで値域を補います。判別共用体を使って関連するパラメータのセットをまとめると、エージェントが矛盾した値を生成する余地を減らせます。
次のコードは、これらを適用したフェードイン・フェードアウトのラッパーコンポーネントです。
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
Easing,
} from "remotion";
/**
* 子要素をフェードイン・フェードアウトで包むラッパー。
*/
export type FadeWrapProps = {
/** フェードインにかけるフレーム数。 @range [0, 60] @default 15 */
fadeIn: number;
/** フェードアウトにかけるフレーム数。 @range [0, 60] @default 15 */
fadeOut: number;
easing: "linear" | "ease-in" | "ease-out" | "ease-in-out";
children: React.ReactNode;
};
const EASING_MAP = {
linear: Easing.linear,
"ease-in": Easing.in(Easing.quad),
"ease-out": Easing.out(Easing.quad),
"ease-in-out": Easing.inOut(Easing.quad),
} satisfies Record<FadeWrapProps["easing"], (t: number) => number>;
export const FadeWrap: React.FC<FadeWrapProps> = ({
fadeIn,
fadeOut,
easing,
children,
}) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
// fadeIn=0 のとき入力配列に重複が生じるため、最低1フレームを確保する
const safeIn = Math.max(fadeIn, 1);
const safeOut = Math.max(fadeOut, 1);
const opacity = interpolate(
frame,
[0, safeIn, durationInFrames - safeOut, durationInFrames],
[0, 1, 1, 0],
{
easing: EASING_MAP[easing],
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
}
);
return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
};
satisfies演算子を使うことで、EASING_MAPのキーがFadeWrapProps["easing"]の列挙値と一致しているかをコンパイル時に検証しています。durationInFramesをuseVideoConfigから取得しているため、このコンポーネントは任意のfpsと任意の長さのSequenceに正しく適応します。
エージェントが渡すべき値はフレーム数の整数と4択のeasingのみで、値域は@rangeアノテーションで明示されています。
コンポーネントレジストリ
複数のコンポーネントをエージェントが選択するためには、ライブラリ全体のインベントリが必要です。型定義ファイルを並べるだけでは不十分で、各コンポーネントが「何をするものか」「どの場面で使うか」をエージェントが参照できる形式で提供します。
// registry.ts
export type ComponentEntry = {
/** コンポーネントの役割を一文で説明する */
description: string;
/** エージェントがコンポーネントを選ぶ際の用途キーワード */
useCases: string[];
/** Propsの例(エージェントが参照するデフォルト値) */
exampleProps: Record<string, unknown>;
/** このコンポーネントが要求するRemotionの最小バージョン */
remotionMinVersion: string;
};
export const componentRegistry = {
FadeWrap: {
description:
"子要素をフェードイン・フェードアウトで包むラッパー。テキスト・画像・任意のReactノードに使用できる。",
useCases: ["タイトル登場", "シーン転換", "要素の退場"],
exampleProps: { fadeIn: 15, fadeOut: 15, easing: "ease-out" },
remotionMinVersion: "4.0.0",
},
SlideIn: {
description:
"指定した方向からスライドしながら登場するアニメーションコンポーネント。",
useCases: ["リスト項目の登場", "テキストの強調", "サイドパネル"],
exampleProps: { direction: "left", distancePx: 60, durationInFrames: 20, easing: "ease-out" },
remotionMinVersion: "4.0.0",
},
} satisfies Record<string, ComponentEntry>;
このレジストリをJSONとしてシリアライズすれば、エージェントのツール呼び出しの応答として渡せます。エージェントはuseCasesを参照して用途に合ったコンポーネントを選び、examplePropsをベースに値を調整します。
springアニメーションのラッパー設計
Remotionのspring関数はフレームとfpsを直接引数に取るため、エージェントが呼び出すと値域の扱いが難しくなります。ライブラリ側でラップし、エージェントに見せるAPIを物理的に意味のある変数に置き換えると、生成品質が上がります。
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
/**
* springアニメーションの現在値(from〜to)を返すフック。
*
* @param stiffness - 物理的な硬さ。値が大きいほど素早く収束する。 @range [50, 400] @default 120
* @param dampingRatio - 減衰比。1.0 以上でオーバーシュートなし。 @range [0.5, 2.0] @default 1.0
* @param delay - アニメーション開始を遅らせるフレーム数。 @range [0, 120] @default 0
* @param from - 開始値。 @default 0
* @param to - 終了値。 @default 1
*/
export function useSpringValue({
stiffness = 120,
dampingRatio = 1.0,
delay = 0,
from = 0,
to = 1,
}: {
stiffness?: number;
dampingRatio?: number;
delay?: number;
from?: number;
to?: number;
} = {}) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// dampingRatio(減衰比 ζ)をRemotionのdamping係数に変換する
// c = 2 * ζ * sqrt(k)(質量 m = 1 を仮定)
const progress = spring({
frame: Math.max(0, frame - delay),
fps,
config: {
stiffness,
damping: dampingRatio * 2 * Math.sqrt(stiffness),
},
});
return from + progress * (to - from);
}
エージェントはこのフックを使うことで、fpsの値を意識せずにspringアニメーションを組み込めます。dampingRatio = 1.0が臨界減衰でオーバーシュートなし、0.7前後でほど良い弾力感が出ると伝えるだけで、エージェントは適切な値を選べます。@rangeアノテーションがその範囲をLLMのコンテキストに渡します。
Sequenceを使った合成パターン
複数のコンポーネントをタイムライン上に並べるとき、Sequenceのオフセットをハードコードせずに計算で求めるパターンが、エージェントにとって扱いやすいAPIを生み出します。
import { AbsoluteFill, Sequence, useVideoConfig } from "remotion";
type TimelineItem = {
component: React.ReactNode;
/** 開始フレーム */
from: number;
/** 表示フレーム数。未指定の場合は合成の残り長さ */
durationInFrames?: number;
};
/**
* タイムラインアイテムの配列を受け取り、Sequenceとして配置するコンポーネント。
*/
export const Timeline: React.FC<{ items: TimelineItem[] }> = ({ items }) => {
const { durationInFrames } = useVideoConfig();
return (
<AbsoluteFill>
{items.map((item, i) => (
<Sequence
key={i}
from={item.from}
durationInFrames={
item.durationInFrames ?? durationInFrames - item.from
}
>
{item.component}
</Sequence>
))}
</AbsoluteFill>
);
};
エージェントがこのTimelineコンポーネントを使う場合、渡すべきデータ構造はTimelineItem[]のみです。Sequenceのネスト構造を直接組み立てる必要がなく、エージェントが生成するコードは次のようになります。
<Timeline
items={[
{ component: <TitleCard text="Chapter 1" />, from: 0, durationInFrames: 60 },
{
component: (
<FadeWrap fadeIn={10} fadeOut={10} easing="ease-out">
<BodyText text="..." />
</FadeWrap>
),
from: 50, // タイトルカードとオーバーラップ
},
]}
/>
from: 50でタイトルカードと意図的にオーバーラップさせる表現も、エージェントが自然に記述できます。
自然言語メタデータとツール定義
エージェントが型定義を超えた情報を取得できるよう、各コンポーネントに静的なメタデータを付与するパターンが有効です。
FadeWrap.meta = {
category: "transition",
description: "子要素をフェードイン・フェードアウトで包む",
agentTips: [
"fadeIn + fadeOut の合計が durationInFrames を超えないようにする",
"テキスト登場には easing: 'ease-out' が最も自然に見える",
"静止画に使う場合、FadeWrapの外側ではなく内側にAbsoluteFillを置く",
],
} as const;
agentTipsフィールドは、型情報では伝えきれない実装上の制約をエージェントに渡すためのものです。型システムで強制するよりも自然言語で伝えた方が、エージェントの生成品質が向上するケースがあります。
エージェントが関数呼び出し形式でコンポーネントを操作する場合は、ZodスキーマをJSON Schemaに変換してLLMのツール定義にそのまま使えます。
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const FadeWrapSchema = z.object({
fadeIn: z
.number()
.int()
.min(0)
.max(60)
.default(15)
.describe("フェードインのフレーム数"),
fadeOut: z
.number()
.int()
.min(0)
.max(60)
.default(15)
.describe("フェードアウトのフレーム数"),
easing: z
.enum(["linear", "ease-in", "ease-out", "ease-in-out"])
.default("ease-out")
.describe("イージング関数の種別"),
});
// Anthropic Messages APIのtool定義として使える形式
export const fadeWrapTool = {
name: "render_fade_wrap",
description:
"子要素をフェードイン・フェードアウトで包むコンポーネントのPropsを生成する。テキスト・画像の登場・退場シーンに使用する。",
input_schema: zodToJsonSchema(FadeWrapSchema),
};
Zodスキーマが唯一の情報源として機能し、型定義・バリデーション・ツール定義の三点に自動的に展開されます。コンポーネントの仕様を変更したとき、Zodスキーマを更新するだけで三点が同期します。
まとめ
エージェントが使いやすいRemotionコンポーネントライブラリは、人間向けのものとは設計の重点が異なります。
タイミングの正規化とJSDocによる値域の明示は、エージェントが間違った値を渡す確率を下げます。コンポーネントレジストリとツール定義の統合は、エージェントがライブラリの全体像を把握してコンポーネントを選択できる基盤を作ります。agentTipsのような自然言語メタデータは、型システムが表現できない実装上の制約を補います。
既存のライブラリをエージェント対応に改修する場合は、JSDocと@rangeアノテーションの追加から始めると、コードを変更せずに改善の効果が得られます。RenderCompのテンプレートカタログに収録されているコンポーネントも、同様の設計原則に従ってAPIを整備しています。springのラッパーやレジストリパターンは、規模にかかわらずそのまま適用できます。