R RenderComp
remotion personalized-video automation programmatic-video marketing

Remotionで大量のパーソナライズ動画を生成する方法

Remotionで大量のパーソナライズ動画を生成する方法

大量のパーソナライズ動画とは、データ1行につき1本のユニークな動画を作ること——受信者ごとの名前、その人の数値、その人の画像を、タイムラインを一切手作業で編集せずに動画へ落とし込むことを意味します。これを現実的にするパターンは至ってシンプルです。propsからコンテンツを読み込むRemotionコンポジションを1つ書き、そこにデータセットを流し込んで全行を並列レンダリングする。テンプレートは1つ、出力は数千本です。

Remotionのコンポジションは1枚のReactコンポーネントなので、「パーソナライズ」とは要するに異なるpropsを渡すだけのことです。CSVの1行、データベースのレコード、CRMのAPIレスポンス——これらがpropsオブジェクトになり、そのpropsオブジェクトが1本の動画になります。これを大量でも安定して回すための要素は3つあります。inputProps(レンダリング時に注入する受信者ごとのデータ)、calculateMetadata(レンダリング前にデータを取得し、各動画の尺と寸法を決定する)、そして @remotion/lambda(各レンダリングを独立した水平スケール可能なジョブとして実行する)です。

本記事ではデータソースから納品ファイルまで、パイプライン全体のアーキテクチャを、各段階を担う具体的なRemotion APIとともに解説します。目指すのは、スプレッドシートに向けて実行すれば全エントリについて正確でブランドに沿った動画を生成してくれる——そう信頼できるパイプラインです。


「大量のパーソナライズ動画」とは何か

パーソナライズ動画は1:1の成果物です。1本の動画が、ただ1人の受信者に属します。これを大量に扱うときは、手作業の編集ではなく構造化データから生成します。よくある形はいくつかあります。

  • セールス・オンボーディング — 「ようこそ、Priyaさん」に、そのアカウントのプラン名と開始日を添える。
  • 年次まとめ(Year-in-review / Wrapped) — ユーザーごとの合計値・トップアイテム・マイルストーン。
  • イベント・ウェビナー — 参加者のセッション時刻を入れたパーソナルなリマインドカード。
  • EC・購入後フォロー — 顧客が実際に買った商品を、サンクスクリップに描き込む。

決定的な性質は、データがコンテンツを駆動すること、そして多くの場合は構造まで駆動することです。受信者によって明細が3項目の人もいれば7項目の人もいる。名前が短い人もいれば、2行に折り返す人もいる。固定スロットの手作りテンプレートはここで苦戦します。しかしコード駆動のテンプレートなら問題ありません。レイアウトもタイミングも、そして動画の尺すらも、レンダリング時にデータから計算できるからです。

メンタルモデルは、Remotionにおけるすべてのプログラマティック動画を支えるものと同じです。あらゆるフレームはフレーム番号の関数であり、あらゆるコンポジションはそのpropsの関数です。

data row → props → React composition → frames → MP4

アーキテクチャの全体像

パイプラインを番号付きのシーケンスとして示します。各段階が具体的なRemotionの概念に対応します。

  1. データソース。 データがどこにあろうと、そこから行を読み込みます——CSVエクスポート、SQLクエリ、CRM/APIレスポンスなど。1行が1本の動画の入力になります。
  2. 行 → props。 各行を、コンポジションのスキーマに合った型付きpropsオブジェクトに変換します({ name, plan, total, avatarUrl, ... })。
  3. バンドルは一度だけ。 Remotionプロジェクトのビルド(またはデプロイ)は一度だけ行います。同じバンドルをすべてのレンダリングで使い回すので、このコストは動画1本ごとではなく、パイプライン1回の実行につき一度だけ支払います。
  4. 動画ごとのメタデータを解決。 各行について、calculateMetadata が追加データを取得し、フレームが描かれる前にその動画の durationInFrameswidthheight を計算できます。
  5. レンダリングをファンアウト。 1行につき1つのレンダリングジョブをディスパッチします。各レンダリングは独立しているので水平にスケールします——ローカルではループを回し、Lambdaでは並行実行します。
  6. 収集して納品。 各ジョブがMP4(必要ならサムネイルの静止画も)をストレージに書き出します。納品ステップがそのURLをメール送信・埋め込み・引き渡しします。

以降ではRemotion固有の段階——propsの受け渡し、calculateMetadata、並列レンダリング、そしてアセット・サムネイル・納品まわりの実務的な論点——を詳しく見ていきます。


inputPropsとdefaultPropsで受信者ごとのデータを渡す

コンポジションは、受け取るデータの形を宣言し、defaultProps で妥当なフォールバックを用意します。コンポーネントは純粋に保ちます。propsを読んで描画するだけで、自分からデータを取りに行くことは決してしません。

import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from "remotion";

export type WelcomeProps = {
  name: string;
  plan: string;
  avatarUrl: string;
};

export const Welcome: React.FC<WelcomeProps> = ({ name, plan, avatarUrl }) => {
  const frame = useCurrentFrame();

  // Animate over frames — never with CSS transitions/animations,
  // which do not render in Remotion. Drive motion from useCurrentFrame().
  const opacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateRight: "clamp",
    easing: Easing.bezier(0.16, 1, 0.3, 1),
  });
  const translateY = interpolate(frame, [0, 20], [24, 0], {
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: "#0B84FF",
        alignItems: "center",
        justifyContent: "center",
        color: "#fff",
        fontFamily: "-apple-system, Segoe UI, Roboto, sans-serif",
      }}
    >
      <div style={{ opacity, translate: `0px ${translateY}px`, textAlign: "center" }}>
        <div style={{ fontSize: 84, fontWeight: 700 }}>Welcome, {name}</div>
        <div style={{ fontSize: 40, opacity: 0.85 }}>You're on the {plan} plan</div>
      </div>
    </AbsoluteFill>
  );
};

コンポジションを defaultProps 付きで登録し、Studioでもレンダリングでも有効な初期状態を持たせます。

import { Composition } from "remotion";
import { Welcome, WelcomeProps } from "./Welcome";

export const RemotionRoot: React.FC = () => (
  <Composition
    id="Welcome"
    component={Welcome}
    durationInFrames={120}
    fps={30}
    width={1080}
    height={1080}
    defaultProps={{
      name: "there",
      plan: "Starter",
      avatarUrl: "https://example.com/placeholder.png",
    } satisfies WelcomeProps}
  />
);

レンダリング時には defaultProps を行のデータで上書きします。CLIからは --props を渡し、プログラム的にはレンダリング呼び出しに inputProps を渡します。データセットをループして1行につき1本の動画をレンダリングすれば、それだけでバッチ処理は完結します。

import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";

const recipients = await loadRecipientsFromCsv("./recipients.csv");

// Bundle once, reuse for every render.
const serveUrl = await bundle({ entryPoint: "./src/index.ts" });

for (const person of recipients) {
  const composition = await selectComposition({
    serveUrl,
    id: "Welcome",
    inputProps: person, // { name, plan, avatarUrl }
  });

  await renderMedia({
    composition,
    serveUrl,
    codec: "h264",
    inputProps: person,
    outputLocation: `out/${person.id}.mp4`,
  });
}

selectCompositionrenderMedia に渡す inputPropsdefaultProps に対してディープマージされるので、各動画は受信者ごとのフィールドを正確に受け取ります。行が変わってもコンポーネント自体は何も変わりません。変わるのはデータだけです。


calculateMetadataで尺と寸法を動的にする

パーソナライズされたデータは不揃いです。あるユーザーの振り返りにはハイライトが3件、別のユーザーには12件あります。durationInFrames を固定してしまうと、長い方は駆け足になり、短い方は間延びします。calculateMetadata はこれを解決します。フレームが1枚も描かれる前に、レンダリングごとにメタデータを計算するのです。

calculateMetadata<Composition> 上の非同期関数です。解決済みの props(および abortSignal)を受け取り、durationInFrameswidthheightfps・変換後の propsdefaultOutName のいずれかを返します。返した値はコンポジションの静的propsを上書きします。Remotionのドキュメント(docs.remotion.dev/docs/dynamic-metadata)によれば、これはStudioでもレンダリング時でも実行されるため、プレビューと出力の一貫性が保たれます。

import { Composition, CalculateMetadataFunction } from "remotion";
import { Recap, RecapProps } from "./Recap";

const FPS = 30;
const SECONDS_PER_HIGHLIGHT = 3;

const calculateMetadata: CalculateMetadataFunction<RecapProps> = async ({
  props,
  abortSignal,
}) => {
  // Fetch the recipient's data by id — the CSV only carried the key.
  const res = await fetch(`https://api.example.com/recap/${props.userId}`, {
    signal: abortSignal, // cancels stale requests when props change in Studio
  });
  const data = await res.json();

  const seconds = 2 + data.highlights.length * SECONDS_PER_HIGHLIGHT;

  return {
    durationInFrames: Math.ceil(seconds * FPS),
    // Vertical for a Stories-style recipient, square otherwise.
    width: data.vertical ? 1080 : 1080,
    height: data.vertical ? 1920 : 1080,
    props: { ...props, highlights: data.highlights },
    defaultOutName: `recap-${props.userId}`,
  };
};

export const RemotionRoot: React.FC = () => (
  <Composition
    id="Recap"
    component={Recap}
    fps={FPS}
    durationInFrames={120}
    width={1080}
    height={1080}
    defaultProps={{ userId: "demo", highlights: [] } satisfies RecapProps}
    calculateMetadata={calculateMetadata}
  />
);

これがパーソナライズにおいて強力である理由は2つあります。第一に、CSVやジョブのペイロードにはIDだけを持たせておき、calculateMetadata が完全なレコードを取得できます。これによってバッチのペイロードは小さく、データはつねに正となる情報源から取れます。第二に、各動画の尺とアスペクト比が、その動画自身のデータの関数になります——ワンサイズのテンプレートではなく、まさに受信者ごとのタイムラインです。


@remotion/lambdaで大量に並列レンダリングする

「大量」を現実にする性質は、各レンダリングが独立していることです。どのレンダリングも他のレンダリングの出力に依存しないので、この処理は理想的に並列化できます。ローカルではループ(またはワーカープール)を意味し、数千本の動画であれば @remotion/lambda がAWS Lambda上に処理を分散させ、水平にスケールさせます。

セットアップは一度きりの投資です。レンダリング関数をデプロイし、バンドルをLambdaの「サイト」としてアップロードします。それが済めば、レンダリングのディスパッチは受信者ごとに1回の関数呼び出しだけです。鍵となる関数は renderMediaOnLambda で、進捗は getRenderProgress で追跡します。

import { renderMediaOnLambda, getRenderProgress } from "@remotion/lambda/client";

const common = {
  region: "us-east-1" as const,
  functionName: "remotion-render-xxxxx",
  serveUrl: "https://remotionlambda-xxxxx.s3.amazonaws.com/sites/welcome/index.html",
  composition: "Welcome",
  codec: "h264" as const,
};

async function renderOne(person: WelcomeProps & { id: string }) {
  const { renderId, bucketName } = await renderMediaOnLambda({
    ...common,
    inputProps: person, // per-recipient data
  });

  // Poll until the file is ready.
  while (true) {
    const progress = await getRenderProgress({
      renderId,
      bucketName,
      functionName: common.functionName,
      region: common.region,
    });
    if (progress.done) return progress.outputFile; // URL to the MP4
    if (progress.fatalErrorEncountered) throw new Error(progress.errors[0]?.message);
    await new Promise((r) => setTimeout(r, 1000));
  }
}

// Fan out — dispatch many at once, bounded by your concurrency limit.
const urls = await Promise.all(recipients.map(renderOne));

renderMediaOnLambda の各呼び出しはそれぞれ独立したレンダリングなので、多数を同時に起動できます——実用上の上限はAWS Lambdaのコンカレンシー制限であり、これは設定変更が可能です。コストはレンダリングごとの計算時間に連動し、バッチサイズにかかわらず動画1本あたりはほぼ一定に保たれます。Google Cloudを使うチームには @remotion/cloudrun が同じ分散モデルを提供します。いずれにせよ、コンポジションとpropsはローカルのStudioでプレビューするものと同一です。Lambdaが変えるのはフレームがどこでレンダリングされるかであって、どうレンダリングされるかではありません。


実務的な論点:アセット・サムネイル・納品

アセットのキャッシュ。 パーソナライズ動画はたいていリモート画像を取得します——アバター、商品画像、ロゴなど。レンダリングのたびに同じブランドアセットを取りに行くのは無駄です。共有される静的アセットはプロジェクトの public/ フォルダにバンドルし、staticFile() で参照すればサイトと一緒に配布され、レンダリングごとに再ダウンロードされません。リモートURLは本当に受信者ごとに異なる画像だけに留めましょう。可能であれば、それらはキャッシュ有効期間の長いCDNから配信し、繰り返しのレンダリングがウォームキャッシュにヒットするようにします。

パーソナライズされたサムネイル。 パーソナライズ動画には、パーソナライズされたポスターフレームがふさわしいものです——メールやプレイヤーで再生前に表示される静止画のことです。受信者ごとに1枚の静止画を renderStillOnLambda(ローカルなら renderStill)でレンダリングし、同じコンポジションと inputProps に狙った frame を指定します。これで、クリップ全体を再エンコードすることなく、同じデータに対応する「画面に名前が入った」サムネイルが手に入ります。

import { renderStillOnLambda } from "@remotion/lambda/client";

const still = await renderStillOnLambda({
  ...common,
  imageFormat: "jpeg",
  frame: 30, // 1s in at 30fps; a moment where the name is on screen
  inputProps: person,
});
// still.url → personalized thumbnail

納品。 レンダリング結果はオブジェクトストレージ(LambdaならS3)にURLとして格納されます。そこから先の納品はアプリケーション側の役割です。URLを受信者に紐づけて保存する、@remotion/player で埋め込んでアプリ内再生する、あるいはメールで送る、といった具合です。すべてのジョブが安定した受信者IDでキーづけされているので、そのIDを defaultOutName(または出力キー)に使えば、ファイル名は予測可能で冪等になります——再実行しても重複せず、きれいに上書きされます。

冪等性とリトライ。 各レンダリングをリトライ可能な単位として扱いましょう。ある行が失敗しても(アバターURLの不備、一時的な取得エラーなど)、その行だけをリトライします——レンダリングを並列にするその独立性が、リカバリのコストも安くしてくれるのです。


サマリー表

段階Remotionの仕組み何をするか
受信者ごとのデータ入力defaultProps に対する inputProps / --props1行分のフィールドをコンポジションに注入する
コンポジションの契約<Composition> 上の型付きpropsデータの形とフォールバックを宣言する
取得 + 動的タイミングcalculateMetadata完全なレコードを取得し、動画ごとに尺・寸法・propsを設定する
アニメーションuseCurrentFrame() + interpolate()フレーム駆動のモーション(CSSトランジションはレンダリングされない)
並列レンダリングrenderMediaOnLambda / @remotion/cloudrun各レンダリングを独立に実行し水平スケールさせる
サムネイルrenderStillOnLambda / renderStill受信者ごとのパーソナライズされたポスターフレーム
共有アセットstaticFile() + public/共通アセットをバンドルと一緒に配布し、再取得を避ける

FAQ

Q: Remotionはどのデータがどの動画に入るのかをどうやって知るのですか? 明示的に渡します。各レンダリング呼び出しは inputProps オブジェクト(CLIフラグは --props)を受け取り、それがコンポジションの defaultProps に対してマージされます。データセットの1行が1つの inputProps オブジェクトになり、それが1本の動画になります。推論される部分は一切なく、対応づけはあなたが制御するデータです。

Q: defaultPropsとinputPropsの違いは何ですか? defaultProps<Composition> に宣言するフォールバック値で、追加入力なしでもStudioやレンダリングを有効にします。inputProps はレンダリング時に供給する、レンダリングごとの上書き値です。Remotionは inputPropsdefaultProps に対してマージするので、受信者ごとに変わるフィールドだけを渡せば済みます。

Q: 動画ごとに尺やアスペクト比を変えられますか? 変えられます。calculateMetadata はその動画のpropsから計算した durationInFrameswidthheight をレンダリングごとに返します。ハイライトの多い振り返りは長くなり、Storiesフォーマットの受信者は縦向きでレンダリングされます。返された値はコンポジションの静的設定を上書きします。

Q: 何本まで並列にレンダリングできますか? 各レンダリングは独立しているので、並列度はRemotionではなくインフラによって制限されます。@remotion/lambda では上限はAWS Lambdaのコンカレンシー制限で、これは設定変更が可能です。ローカルではマシンのコア数やワーカープールのサイズが上限です。

Q: なぜCSSトランジションやキーフレームアニメーションが使えないのですか? Remotionはフレームを1枚ずつレンダリングし、各フレームを独立してキャプチャします。そのため時間ベースのCSSトランジションやアニメーションはモーションとしてレンダリングされません。すべてのアニメーションは useCurrentFrame() から駆動し、interpolate()(または spring)でマッピングして、各フレームで値が決定論的になるようにしてください。

Q: パーソナライズ用のデータはどこから来るのですか——ライブAPIでも可能ですか? どちらも可能です。ディスパッチ時にCSVやデータベースから inputProps を組み立てることもできますし、calculateMetadata の中で受信者のIDを使って追加データを取得することもできます。abortSignal 引数によって、Studioでpropsが変わったときにRemotionが古いフェッチをキャンセルできます。

Q: これを行うのにRemotionのライセンスは必要ですか? Remotionは個人利用とオープンソース利用では無料ですが、商用利用には企業向けライセンスが必要です。価格は企業単位で設定されています——本番パイプラインを構築する前に、remotion.dev/docs/license で最新の条件を確認してください。


RenderCompでもっと速く作る

大量のパーソナライズはRemotionにおいて解決済みのアーキテクチャです——残る作業は、propsをきれいに読み込むコンポジションを設計することだけ。そしてそここそ、テンプレートライブラリが時間を節約してくれる領域です。

RenderComp はプロダクション品質のRemotionテンプレートを提供します——ウェルカムカード、データ振り返り、ロワーサード、SNS向けオープナーなど。いずれもTypeScriptソースとして配布され、レンダリングパイプラインから inputProps を受け取れるよう設計された型付きpropインターフェースを備えています。すでにデータからコンテンツを読み込むコンポジションを起点に、それを自社ブランドに合わせて調整し、あなたのデータセットに向けて実行するだけです。

テンプレート一覧は rendercomp.com でご確認いただき、初日からパーソナライズ動画のレンダリングを始めてください。

販売中

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

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

料金プランを見る →