CSVからRemotionをバッチレンダリングする
執筆: RenderComp チーム 編集方針
Remotionのフレームは確定的です。同じinputPropsで何度レンダリングしても出力されるピクセルは変わらず、inputPropsを変えれば異なる動画が生成されます。100行のCSVがあれば、同じコンポジションから100本の固有の動画を作れます。
データソースとしてCSVが適しているのは、スプレッドシートから直接エクスポートできるからです。Remotionの公式ドキュメントはrenderMedia()のinputPropsに「Must be a JSON object.」と規定しています。CSVパーサーが返すオブジェクトをそのまま渡せるため、変換処理は最小限で済みます。
テンプレートを修正すれば全行に反映されます。フォントを変えれば全行、アニメーションのタイミングを変えれば全行です。動画をコードとして扱うことで、個別のAfter Effectsプロジェクトを行数分管理する手間がなくなります。
CSVを読み込む
Node.jsでCSVを扱うにはcsv-parseパッケージが広く使われています。synchronous APIを使うとファイル読み込みとパースを1回の呼び出しで完結できます。
// scripts/parse-csv.ts
import { readFileSync } from "fs";
import { parse } from "csv-parse/sync";
export type VideoRow = {
name: string;
subtitle: string;
};
export function loadRows(csvPath: string): VideoRow[] {
const content = readFileSync(csvPath, "utf-8");
return parse(content, {
columns: true, // 1行目のヘッダーをキーとして使う
skip_empty_lines: true,
trim: true, // フィールド前後の空白を除去
}) as VideoRow[];
}
columns: trueを指定すると先頭行のフィールド名がオブジェクトのキーになります。CSVのヘッダーをinputPropsのキー名と一致させておくことで、行データをスプレッド演算子でそのままPropsに渡せます。
コンポジションを定義する
バッチ処理の前に、データを受け取るコンポジション側の型を確定させます。
// src/compositions/SalesCard.tsx
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
type Props = {
name: string;
subtitle: string;
};
export const SalesCard: React.FC<Props> = ({ name, subtitle }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: { stiffness: 80, damping: 20 },
});
const opacity = progress;
const translateY = interpolate(progress, [0, 1], [40, 0]); // 40px 下から上へ
return (
<AbsoluteFill
style={{
background: "#0f0f0f",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
textAlign: "center",
}}
>
<h1 style={{ color: "#fff", fontSize: 64, margin: 0 }}>{name}</h1>
<p style={{ color: "#aaa", fontSize: 32, margin: "16px 0 0" }}>
{subtitle}
</p>
</div>
</AbsoluteFill>
);
};
spring()はフレーム番号を物理シミュレーションで0から1に補間します。stiffness 80 / damping 20はレスポンスが早く振動が少ない組み合わせで、interpolate()でその出力を40pxから0pxへの移動に変換しています。
バッチレンダリングスクリプト
CSVの全行を順に処理するスクリプトです。selectComposition()でコンポジションのメタデータを取得してからrenderMedia()に渡すのが公式の推奨パターンです。
// scripts/batch-render.ts
import { renderMedia, selectComposition } from "@remotion/renderer";
import { loadRows } from "./parse-csv";
import { mkdirSync } from "fs";
import path from "path";
const SERVE_URL = "http://localhost:3000"; // devサーバーURL、またはbundle()の出力パス
const COMPOSITION_ID = "SalesCard";
const OUTPUT_DIR = "output";
async function main() {
mkdirSync(OUTPUT_DIR, { recursive: true });
const rows = loadRows("data/sales.csv");
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const inputProps = {
name: row.name,
subtitle: row.subtitle,
};
const composition = await selectComposition({
serveUrl: SERVE_URL,
id: COMPOSITION_ID,
inputProps,
});
const outputLocation = path.join(OUTPUT_DIR, `${i + 1}-${row.name}.mp4`);
await renderMedia({
composition,
serveUrl: SERVE_URL,
codec: "h264",
outputLocation,
inputProps,
});
console.log(`rendered: ${outputLocation}`);
}
}
main().catch(console.error);
selectComposition()に渡すinputPropsとrenderMedia()に渡すinputPropsは同一にする必要があります。後述のcalculateMetadata()が定義されている場合、selectComposition()はその変換済みのdurationInFramesを返し、renderMedia()はその値でレンダリングを実行します。
calculateMetadata() で行ごとにデュレーションを変える
テキストの長さが行によって異なる場合、固定のdurationInFramesでは余白が生じます。calculateMetadata()はinputPropsを受け取り、コンポジションのメタデータを動的に変換できます。
// src/compositions/SalesCard.tsx に追加
import { CalculateMetadataFunction } from "remotion";
export const calculateMetadata: CalculateMetadataFunction<Props> = ({
props,
}) => {
const base = 90; // 3秒(30fps基準)
const extra = Math.min(
Math.floor(props.subtitle.length / 10) * 10, // 10文字ごとに+10フレーム
60 // 上限+60フレーム(+2秒)
);
return { durationInFrames: base + extra };
};
コンポジションの登録時にこの関数を渡します。
// src/Root.tsx
import { Composition } from "remotion";
import { SalesCard, calculateMetadata } from "./compositions/SalesCard";
export const RemotionRoot: React.FC = () => (
<Composition
id="SalesCard"
component={SalesCard}
durationInFrames={90} // calculateMetadataが実行時に上書きする初期値
fps={30}
width={1280}
height={720}
calculateMetadata={calculateMetadata}
defaultProps={{ name: "Preview", subtitle: "" }}
/>
);
calculateMetadata()はサーバーサイドで実行されるため、useCurrentFrame()などのRemotionフックは使えません。propsから戻り値を算出する純粋な関数として書きます。
Windows シェルでのプロパティ渡し
CLIから--propsフラグを使う場合、Windowsシェルはダブルクォートを除去するためインラインJSONが機能しません。代わりにJSONをファイルに書き出し、そのパスを指定します。
// scripts/write-props.ts
import { writeFileSync } from "fs";
const props = { name: "田中一郎", subtitle: "Q3 Sales Report" };
writeFileSync("tmp-props.json", JSON.stringify(props, null, 2));
// 実行: npx remotion render SalesCard out.mp4 --props=tmp-props.json
renderMedia()を直接呼び出すNode.jsスクリプトはこの制約を受けません。Windows上でCLIを使うバッチスクリプトを組む場合にのみ必要な対応です。
並列化
直列のforループは行数に比例して時間がかかります。Remotionは自前のインフラ上で大量の動画をバッチレンダリングする機能を持っており、単一マシンでの並列化もp-limitで制御できます。
// scripts/batch-render-parallel.ts
import pLimit from "p-limit";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { loadRows } from "./parse-csv";
import { mkdirSync } from "fs";
import path from "path";
const SERVE_URL = "http://localhost:3000";
const COMPOSITION_ID = "SalesCard";
const OUTPUT_DIR = "output";
async function main() {
mkdirSync(OUTPUT_DIR, { recursive: true });
const rows = loadRows("data/sales.csv");
const limit = pLimit(3); // 最大3本を同時処理
await Promise.all(
rows.map((row, i) =>
limit(async () => {
const inputProps = { name: row.name, subtitle: row.subtitle };
const composition = await selectComposition({
serveUrl: SERVE_URL,
id: COMPOSITION_ID,
inputProps,
});
await renderMedia({
composition,
serveUrl: SERVE_URL,
codec: "h264",
outputLocation: path.join(OUTPUT_DIR, `${i + 1}.mp4`),
inputProps,
});
})
)
);
}
main().catch(console.error);
注意点として、renderMedia()のconcurrencyオプションは1本の動画内部のフレーム分割数を制御するもので、pLimitによる複数動画の同時実行数とは独立した設定です。両方を組み合わせる場合、concurrencyを下げることでマシンあたりのメモリ消費を予測可能な範囲に抑えられます。
無料50本
テンプレート50本を ZIP で受け取る
メールアドレスを入れると、50本の ZIP のリンクをすぐにお送りします。
50本の ZIP をメールで受け取ります。あわせて RenderComp テンプレートの更新と、有料ライブラリを含む製品のご案内を受け取ることに同意します(メールは数通・ワンクリック解除)。 プライバシーポリシー(英語)
メールを使わずに受け取ることもできます。GitHub のリポジトリは公開のままで、登録も不要です。 リポジトリを開く
送信しました
ZIP のリンクを記載したメールをお送りしました。届かない場合は迷惑メールをご確認ください。いますぐ受け取る場合はこちらから直接ダウンロードできます。