R RenderComp
remotion typescript workflow rendering composition

Remotionで動画シリーズを繰り返し納品するエージェンシーワークフロー

執筆: RenderComp チーム 編集方針

Remotionの根本にある約束は単純です。同じpropsを渡せば、同じフレームが返ってくる。フレームを生成するReactコンポーネントは、フレーム番号とpropsを引数に取る純粋な関数として動作するからです。この決定論的な性質は、単発の動画制作では設計上の快適さにとどまります。ところが、エージェンシーが月次・週次でシリーズを量産する場面では、この約束がワークフロー全体を支える柱に変わります。

実案件では「先月と同じ構成で、テキストだけ差し替えてください」という依頼が繰り返し発生します。Remotionの決定論を活かすには、コンポジションコードを変更せずに別のエピソードをレンダリングできるように設計する必要があります。そのためには、コンテンツ(何を表示するか)とコンポジション(どう表示するか)の境界を最初から明確に引かなければなりません。

型付きPropsとZodによるスキーマ検証、Sequenceを使った再利用可能なシーン設計、renderMedia()によるバッチレンダリングを組み合わせることで、繰り返し納品に耐えるワークフローが構築できます。以下ではそれぞれを具体的なコードで示します。


データとコンポジションの分離

エージェンシーワークフローで最初に決めるべきことは「何がデータで、何がコードか」という区別です。タイトルテキスト、ブランドカラー、シーンの長さ、BGMのパスはデータです。フェードインのspring設定、Sequenceのオフセット計算、レイアウトのflexboxパラメータはコードです。

エピソードデータをJSONファイルとして外出しにすると、Remotion CLIの--propsフラグを渡すだけで別エピソードをレンダリングできます。ZodでスキーマをTypeScriptの型と同期させておくと、レンダリング前にデータの整合性を確認できます。

// types/episode.ts
import { z } from "zod";

export const EpisodeSchema = z.object({
  episodeNumber: z.number().int().min(1),
  title: z.string().max(40),
  subtitle: z.string().max(60).optional(),
  accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
  durationInFrames: z.number().int().min(30).max(1800),
  scenes: z.array(
    z.object({
      type: z.enum(["title", "body", "outro"]),
      text: z.string().min(1),
      startFrame: z.number().int().min(0),
      durationInFrames: z.number().int().min(15),
    })
  ),
});

export type Episode = z.infer<typeof EpisodeSchema>;

このスキーマをrenderMedia()呼び出し前にparse()すると、不正なデータが渡された場合にレンダリングが始まる前にエラーが発生します。レンダリング完了後に「テキストが空文字列だった」と気づく事態を防げます。


再利用可能なシーンコンポーネント

シーン単位でコンポーネントを分割し、それぞれがuseCurrentFrame()のローカルフレームを基準に動作するように設計します。Sequenceの内部ではuseCurrentFrame()がシーンの開始フレームを0として返すため、シーンコンポーネントは「自分が何フレーム目に配置されているか」を意識せずに書けます。

// compositions/TitleScene.tsx
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  spring,
  interpolate,
} from "remotion";

type TitleSceneProps = {
  title: string;
  subtitle?: string;
  accentColor: string;
};

export const TitleScene: React.FC<TitleSceneProps> = ({
  title,
  subtitle,
  accentColor,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // stiffness=80, damping=20 は 30fps で約16フレームで収束する
  // 60fps で同じ視覚速度を維持するには durationInFrames=60 に調整する
  const riseProgress = spring({
    frame,
    fps,
    config: { stiffness: 80, damping: 20 },
    durationInFrames: 30,
  });

  const titleOpacity = interpolate(frame, [0, 15], [0, 1], {
    extrapolateRight: "clamp",
  });

  // サブタイトルはタイトルより 8 フレーム遅れて登場する
  const subtitleOpacity = interpolate(frame, [8, 25], [0, 1], {
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0f0f0f",
        justifyContent: "center",
        alignItems: "center",
        flexDirection: "column",
        gap: 24,
      }}
    >
      <h1
        style={{
          color: accentColor,
          fontSize: 72,
          fontWeight: 800,
          margin: 0,
          opacity: titleOpacity,
          transform: `translateY(${interpolate(riseProgress, [0, 1], [40, 0])}px)`,
          fontFamily:
            '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
        }}
      >
        {title}
      </h1>
      {subtitle && (
        <p
          style={{
            color: "#e0e0e0",
            fontSize: 32,
            margin: 0,
            opacity: subtitleOpacity,
            fontFamily:
              '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
          }}
        >
          {subtitle}
        </p>
      )}
    </AbsoluteFill>
  );
};

spring()のdurationInFramesはfpsに依存した上限フレーム数です。stiffness=80、damping=20という設定は30fpsで約16フレーム、60fpsでは約32フレームで視覚的な収束に達します。fpsをまたいでシリーズを運用する場合、durationInFramesをfps * 1(1秒相当)で統一しておくと、fps設定を変更したときにアニメーション速度が変わりません。


Sequenceによるシーン構成

ルートコンポジションはエピソードデータのscenes配列をSequenceにマッピングします。各シーンのfromとdurationInFramesはJSONから読み込むため、シーン数やタイミングをコードに書き直さずに変更できます。

// compositions/EpisodeComposition.tsx
import { AbsoluteFill, Sequence } from "remotion";
import type { Episode } from "../types/episode";
import { TitleScene } from "./TitleScene";
import { BodyScene } from "./BodyScene";
import { OutroScene } from "./OutroScene";

const SCENE_MAP = {
  title: TitleScene,
  body: BodyScene,
  outro: OutroScene,
} as const;

export const EpisodeComposition: React.FC<Episode> = (episode) => {
  return (
    <AbsoluteFill>
      {episode.scenes.map((scene, index) => {
        const Component = SCENE_MAP[scene.type];
        return (
          <Sequence
            key={index}
            from={scene.startFrame}
            durationInFrames={scene.durationInFrames}
            name={`${scene.type}-${index}`}
          >
            <Component
              text={scene.text}
              accentColor={episode.accentColor}
              {...(scene.type === "title"
                ? { title: episode.title, subtitle: episode.subtitle }
                : {})}
            />
          </Sequence>
        );
      })}
    </AbsoluteFill>
  );
};

SequenceのnameプロパティはRemotionのプレビューUIのタイムライン表示に使われます。body-0outro-1のように命名しておくと、どのシーンで問題が起きているかをプレビュー画面上で特定しやすくなります。


バッチレンダリングの自動化

@remotion/rendererパッケージのrenderMedia()は、Node.jsから直接呼び出せるプログラマティックAPIです。エピソードの配列をループしてそれぞれレンダリングするスクリプトは次のように構成します。

// scripts/batch-render.ts
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";
import { EpisodeSchema, type Episode } from "../types/episode";
import episodesJson from "../data/episodes.json";

const BUNDLE_LOCATION = path.resolve("./out/bundle");
const OUTPUT_DIR = path.resolve("./out/videos");

async function renderEpisode(episode: Episode): Promise<void> {
  const validated = EpisodeSchema.parse(episode);

  const composition = await selectComposition({
    serveUrl: BUNDLE_LOCATION,
    id: "EpisodeComposition",
    inputProps: validated,
  });

  const tag = String(validated.episodeNumber).padStart(3, "0");

  await renderMedia({
    composition,
    serveUrl: BUNDLE_LOCATION,
    codec: "h264",
    outputLocation: path.join(OUTPUT_DIR, `ep${tag}.mp4`),
    inputProps: validated,
    // Chromium インスタンスの並列数
    // ビデオ素材を多用する場合は I/O がボトルネックになるため 4〜6 が上限の目安
    concurrency: 4,
  });

  console.log(`ep${tag} rendered`);
}

async function main() {
  const results = await Promise.allSettled(
    (episodesJson as Episode[]).map(renderEpisode)
  );

  const failed = results.filter((r) => r.status === "rejected");
  if (failed.length > 0) {
    console.error(`${failed.length} episode(s) failed`);
    process.exit(1);
  }
}

main();

concurrencyはChromiumインスタンスの並列数です。CPUコアが12個あっても、シーンでビデオ素材を読み込む場合はI/Oがボトルネックになるためconcurrencyをコア数に合わせても速度は頭打ちになります。ビデオ素材を多用するプロジェクトでは4〜6が実用的な上限で、静止画素材のみの場合はCPUコア数の半分程度が目安になります。

Promise.allSettledを使うのは、1本のエピソードが失敗しても残りを継続させるためです。Promise.allを使うと最初の失敗で処理が止まり、後続エピソードのレンダリングが中断されます。


CLIによるフレーム検証

バッチレンダリングの前に、特定フレームを静止画として書き出して視覚確認するステップを挟むと、動画全体をレンダリングするコストをかけずに構成を確認できます。

# プレビューでエピソードデータを読み込む
npx remotion studio --props ./episodes/ep001.json

# タイトルシーンのフレーム 30 を静止画として書き出す
npx remotion still EpisodeComposition \
  --props ./episodes/ep001.json \
  --frame 30 \
  --output ./qa/ep001-frame030.png

# アウトロシーンの最終フレームを確認する(120 フレームのシーンなら 119 フレーム目)
npx remotion still EpisodeComposition \
  --props ./episodes/ep001.json \
  --frame 119 \
  --output ./qa/ep001-outro-last.png

クライアントへの中間確認に静止画を使う場合、タイトル・本編・アウトロそれぞれの代表フレームを書き出す手順をnpmスクリプトにまとめておくと、確認フローを標準化できます。動画全体のレンダリング前に視覚的な承認を得るステップとして機能します。


フレーム境界のバリデーション

scenes配列のstartFrameとdurationInFramesが互いに重ならないか、また全体のdurationInFramesに収まっているかを、レンダリング前にチェックします。Zodのsuperrefineを使うと、クロスフィールドバリデーションをスキーマ定義の中に収められます。

// types/episode.ts: EpisodeSchema に superrefine を追加する
export const EpisodeSchema = z
  .object({
    episodeNumber: z.number().int().min(1),
    title: z.string().max(40),
    subtitle: z.string().max(60).optional(),
    accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
    durationInFrames: z.number().int().min(30).max(1800),
    scenes: z.array(
      z.object({
        type: z.enum(["title", "body", "outro"]),
        text: z.string().min(1),
        startFrame: z.number().int().min(0),
        durationInFrames: z.number().int().min(15),
      })
    ),
  })
  .superrefine((data, ctx) => {
    // 各シーンがエピソード全体に収まっているかチェックする
    data.scenes.forEach((scene, i) => {
      const end = scene.startFrame + scene.durationInFrames;
      if (end > data.durationInFrames) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `scenes[${i}] ends at frame ${end}, exceeds durationInFrames ${data.durationInFrames}`,
          path: ["scenes", i],
        });
      }
    });

    // 開始フレーム順にソートして隣接シーンの重複をチェックする
    const sorted = [...data.scenes].sort(
      (a, b) => a.startFrame - b.startFrame
    );
    for (let i = 1; i < sorted.length; i++) {
      const prev = sorted[i - 1];
      if (prev.startFrame + prev.durationInFrames > sorted[i].startFrame) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `Scene at frame ${sorted[i].startFrame} overlaps with the preceding scene`,
          path: ["scenes"],
        });
      }
    }
  });

エラーメッセージにフレーム番号を含めることで、どのシーンエントリを修正すればよいかを即座に特定できます。このバリデーションをCI/CDパイプラインの最初のステップに置くと、レンダリングジョブのキューに入る前に問題が検出されます。


まとめ

このワークフローの核心は、コンテンツをデータとして外出しにし、コンポジションコードから切り離すことにあります。Zodスキーマがデータとコードの境界を型として固定し、superrefineがフレーム境界の整合性をレンダリング前に保証します。Sequenceのfromとdurationをデータから計算する設計にすることで、コードへの変更なしにシーン数やタイミングが調整できます。

concurrency=4〜6は一般的な出発点ですが、素材のI/O量によって最適値は変わります。実際の素材構成でいくつかの値を計測してから固定するのが現実的です。フレーム境界のバリデーションは実装コストが低く、シリーズ本数が増えるほどその効果が顕著になるため、最初から仕込んでおく価値があります。

RenderCompのカタログにある量産向けテンプレートも、このデータ分離とSequence構成パターンを基本構造として採用しており、新しいシリーズを立ち上げる際の参照点として機能します。

販売中

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

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

料金プランを見る →