Remotionのレンダリング時間を決める3つの変数
執筆: RenderComp チーム 編集方針
Remotionが他の動画生成ツールと根本的に異なるのは、映像を「時間軸上の純粋関数」として扱う点です。useCurrentFrame()が返すフレーム番号を引数に取り、ReactコンポーネントがDOMを決定論的に描画する——この設計は開発体験として優れていますが、レンダリングの実体は「Headless Chromiumが全フレームを1枚ずつスクリーンショットし、ffmpegがエンコードする」という処理の積み上げです。
60fpsで60秒の動画は3,600フレームを意味します。1フレームのレンダリングに200msかかるなら、シリアル処理では単純計算で720秒(12分)。concurrencyを8にすれば理論値90秒ですが、解像度を4Kにすれば1フレームあたりのコストが跳ね上がり、その計算は崩れます。
レンダリング時間を支配するのは「尺(フレーム数)」「解像度(ビューポートサイズ)」「コンポジションの複雑さ」の3変数です。この3つを正しく理解すれば、renderMedia()のパラメータ選択からLambdaの分散戦略まで、すべての最適化判断に根拠が生まれます。
Remotionのレンダリングパイプラインを理解する
renderMedia()を呼び出したとき、内部では次のシーケンスが走ります。
concurrencyの数だけHeadless Chromiumインスタンスを起動- 各インスタンスが担当フレームを順番にレンダリング(
page.screenshot()相当) - スクリーンショットをテンポラリディレクトリに書き出し
- 全フレーム完了後、ffmpegがシーケンスをエンコード
重要な点は「並列化はインスタンス間でのみ有効」ということです。1つのChromiumインスタンスは同時に1フレームしかレンダリングできません。concurrency: 8は「8フレームを同時並行で処理できる」を意味し、1インスタンスあたりの処理速度そのものは変わりません。
import { renderMedia, selectComposition } from "@remotion/renderer";
const composition = await selectComposition({
serveUrl: bundleUrl,
id: "MyComposition",
});
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "h264",
outputLocation: "out/video.mp4",
// デフォルトはCPU論理コア数の半分。メモリが制約になるケースが多いため
// Chromiumインスタンス1つあたり300〜600MBのRAMを消費する前提で上限を決める
concurrency: 6,
// フレームごとのタイムアウト(デフォルト30秒)
// 重いコンポジションや外部リソース取得を行うコンポジションでは増やす
timeoutInMilliseconds: 60_000,
});
concurrencyのデフォルトは利用可能なCPU論理コア数の半分です。ただしメモリが制約になるケースが多く、8GBのマシンでconcurrency: 12を指定すればスワップが発生して逆に遅くなります。安全な上限の目安は (空きRAM GB × 1000) / 500 程度です。
変数1:尺(フレーム数)との線形関係
レンダリング時間と総フレーム数は、コンポジションの複雑さが一定ならほぼ線形に比例します。
import { Composition } from "remotion";
export const RemotionRoot: React.FC = () => {
return (
<>
{/* 30fps × 10秒 = 300フレーム */}
<Composition id="Short" component={MyComp} durationInFrames={300} fps={30} width={1920} height={1080} />
{/* 30fps × 60秒 = 1800フレーム(6倍の処理時間) */}
<Composition id="Long" component={MyComp} durationInFrames={1800} fps={30} width={1920} height={1080} />
{/* 60fps × 60秒 = 3600フレーム(さらに2倍) */}
<Composition id="HighFps" component={MyComp} durationInFrames={3600} fps={60} width={1920} height={1080} />
</>
);
};
fpsの選択はフレーム数に直接影響します。SNS向けの短尺コンテンツなら24fpsや30fpsで十分です。60fpsが必要なのは高速な動きやゲームコンテンツのように「補間の滑らかさ」が価値を持つケースに限定するべきです。
renderMedia()のframeRangeを使えば、特定フレームだけをレンダリングしてデバッグ時間を短縮できます。
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "h264",
outputLocation: "out/preview.mp4",
// 0フレーム目から89フレーム目(30fps × 3秒分)だけレンダリング
// 問題のあるセクションを切り出してフィードバックループを短縮する
frameRange: [0, 89],
});
開発中はframeRangeで問題のあるセクションだけを切り出すことで、フル尺のレンダリングを待たずに確認サイクルを回せます。
変数2:解像度がChromiumのレンダリングコストを決める
RemotionはコンポジションのwidthとheightをそのままChromiumのビューポートサイズとして使います。ビューポートが大きいほど、Chromiumがラスタライズするピクセル数が増加し、CSSトランスフォームやシャドウの計算コストも上昇します。
1080p(1920×1080)と4K(3840×2160)のピクセル数比は4倍です。しかし実際のレンダリング時間の増加は4倍以上になることが多く、CSSのフィルタや複雑なレイアウト計算がピクセル数以上にスケールするためです。
export const RemotionRoot: React.FC = () => {
return (
<>
{/* 確認・プレビュー用: 4分の1ピクセル数で高速確認 */}
<Composition
id="Preview"
component={MyComp}
durationInFrames={300}
fps={30}
width={960}
height={540}
/>
{/* 本番配信用: フルHD */}
<Composition
id="HD"
component={MyComp}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
/>
{/* アーカイブ・マスター用: レンダリング時間に要注意 */}
<Composition
id="UHD"
component={MyComp}
durationInFrames={300}
fps={30}
width={3840}
height={2160}
/>
</>
);
};
同じコンポーネントを使いながらwidthとheightだけが異なるコンポジションIDを複数定義しておくと、本番レンダリングと確認レンダリングをcompositionの引数切り替えだけで使い分けられます。制作フェーズでは常に低解像度IDを使い、最終承認時だけ本番解像度IDに切り替えるワークフローが効果的です。
変数3:コンポジションの複雑さ
「複雑さ」は漠然とした言葉ですが、Remotionにおいては具体的な要因に分解できます。
CSS filterとbox-shadowの負荷
filter: blur()やbox-shadowは、GPUアクセラレーションが効かないヘッドレス環境では特に重い処理です。blur(20px)のような大きなblur値は、Chromiumが広いピクセル領域を参照する必要があり、高解像度コンポジションでは顕著に遅くなります。
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
export const GlowElement: React.FC = () => {
const frame = useCurrentFrame();
const glowIntensity = interpolate(frame, [0, 30], [0, 15], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<div
style={{
// blur値が大きいほどレンダリングが重くなる
// アニメーション中はblurを最小限に抑えるか、
// 事前レンダリング済み画像テクスチャで代替することを検討する
filter: `blur(${glowIntensity}px) brightness(1.2)`,
width: 400,
height: 400,
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
borderRadius: 12,
}}
/>
</AbsoluteFill>
);
};
<OffthreadVideo>と<Video>の選択
動画素材を合成する場合、<OffthreadVideo>は<Video>より速く処理できます。<Video>はChromiumの動画デコーダーを経由しますが、<OffthreadVideo>はffmpegが直接フレームを抽出してChromiumのレンダリングレイヤーに渡します。動画素材を含むコンポジションでは原則として<OffthreadVideo>を使うべきです。
import { AbsoluteFill, OffthreadVideo, staticFile } from "remotion";
export const VideoOverlay: React.FC = () => {
return (
<AbsoluteFill>
{/* <Video>ではなく<OffthreadVideo>を使う
Chromiumのデコーダーを経由せずffmpegが直接フレームを供給するため
特に長尺・高解像度の素材を合成するケースで差が大きい */}
<OffthreadVideo
src={staticFile("background.mp4")}
style={{ width: "100%", height: "100%" }}
/>
</AbsoluteFill>
);
};
フレームごとの重い計算をコンポーネント外に出す
Remotionはフレームごとにコンポーネントを再レンダリングします。コンポーネント関数内に計算量の多いロジックを置くと、そのコストがフレーム数だけ繰り返されます。
import { useCurrentFrame, interpolate } from "remotion";
// NG: 大きな配列操作がフレームごとに実行される
export const BadComp: React.FC = () => {
const frame = useCurrentFrame();
// この配列生成は毎フレーム実行される(300フレームなら300回)
const dataPoints = Array.from({ length: 10_000 }, (_, i) => Math.sin(i * 0.01));
const value = interpolate(frame, [0, 100], [0, 1]);
return <div style={{ opacity: value }}>{dataPoints[frame % dataPoints.length]}</div>;
};
// OK: 定数データはモジュールスコープに置く(1回だけ計算される)
const DATA_POINTS = Array.from({ length: 10_000 }, (_, i) => Math.sin(i * 0.01));
export const GoodComp: React.FC = () => {
const frame = useCurrentFrame();
const value = interpolate(frame, [0, 100], [0, 1]);
return <div style={{ opacity: value }}>{DATA_POINTS[frame % DATA_POINTS.length]}</div>;
};
renderMedia()パラメータの最適化
imageFormatの選択
imageFormatはフレームをディスクに書き出す際のフォーマットを指定します。デフォルトはjpegで、これが多くのケースで最速です。
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "h264",
outputLocation: "out/video.mp4",
// 'jpeg': アルファ不要なら常にこちらを選ぶ
// ファイルサイズが小さくI/O速度が高い
// 'png': アルファチャンネル(透過)が必要な場合のみ
// ファイルサイズがJPEGの3〜5倍になりディスクI/Oがボトルネックになりやすい
imageFormat: "jpeg",
// 0〜100の範囲。80が品質とファイルサイズのバランス点として機能しやすい
// 低すぎるとアーティファクトが目立ちffmpegの処理負荷も上がる
jpegQuality: 80,
});
透過が不要なコンポジションでimageFormat: "png"を使っている場合、jpegへの切り替えだけでレンダリング時間が数十%短縮されることがあります。
codecとcrfの選択
await renderMedia({
composition,
serveUrl: bundleUrl,
// h264: 最速エンコード・最広い互換性。Web配信の第一選択
// prores: エンコードは高速だがファイルサイズが巨大。マスター素材向け
// h265/vp9: エンコードは遅いがファイルサイズが小さい。配信コスト重視の場合
codec: "h264",
// crf: 0(無損失)〜51(最低品質)
// 18〜23が品質重視、24〜28がファイルサイズ重視
// 値が大きいほどffmpegのエンコードが速くなる(ただし品質は下がる)
crf: 22,
outputLocation: "out/video.mp4",
});
GIF出力時のeveryNthFrame
GIF向けのレンダリングではeveryNthFrameが特に効果的です。このパラメータはGIFコーデック専用ではありませんが、GIFは元々15fps程度で十分なめらかに見えるため、30fpsコンポジションからの出力で最も恩恵を受けます。
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "gif",
outputLocation: "out/animation.gif",
// 30fpsコンポジションから2フレームに1枚だけレンダリング→実質15fps出力
// レンダリングフレーム数が半減するため処理時間も約半分になる
// 視覚的な差はほとんどなく、ファイルサイズも同時に削減できる
everyNthFrame: 2,
});
delayRender/continueRenderの落とし穴
非同期データ取得を伴うコンポジションではdelayRender()とcontinueRender()を使いますが、これがレンダリング時間の予期しない増加を引き起こすことがあります。外部APIへのネットワークリクエストをコンポジション内で行うと、concurrencyの数だけ並行リクエストが走り、レンダリング時間にネットワーク遅延が上乗せされます。
データが静的であれば、レンダリング前にデータを取得してinputPropsとして渡すパターンに切り替えるべきです。
// レンダリング前にデータを取得してinputPropsで渡す
// コンポジション内でfetchしないことでネットワーク遅延をレンダリング時間から切り離す
const data = await fetch("https://api.example.com/chart-data").then((r) => r.json());
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "h264",
outputLocation: "out/video.mp4",
inputProps: { data },
});
コンポジション側ではgetInputProps()またはuseCurrentFrame()と同じレイヤーでinputPropsを受け取り、delayRender()を使わずにデータにアクセスできます。
Lambdaで水平スケールする
ローカルレンダリングの速度限界を超えるには、@remotion/lambdaによる分散レンダリングが選択肢になります。Lambdaはコンポジションを複数チャンクに分割して並列処理し、最後にffmpegでスティッチングします。
import { renderMediaOnLambda } from "@remotion/lambda/client";
const { renderId, bucketName } = await renderMediaOnLambda({
region: "ap-northeast-1",
functionName: "remotion-render-4-0-0-mem3000mb-disk2048mb-120sec",
serveUrl: "https://remotionlambda-xxxx.s3.ap-northeast-1.amazonaws.com/sites/my-site/index.html",
composition: "MyComposition",
codec: "h264",
// 1つのLambda関数が担当するフレーム数
// 小さくするほど並列度が上がるがLambdaの起動コスト(コールドスタート)が増える
// 20〜40フレームが多くのケースでバランスが良い
framesPerLambda: 20,
inputProps: {},
});
framesPerLambda: 20で300フレームのコンポジションをレンダリングすると、15個のLambdaが同時起動します。ただしLambdaのコールドスタートと最終的なスティッチング処理のオーバーヘッドがあるため、短尺コンテンツほどLambdaの優位性は薄れます。目安として、2分以上のコンテンツや4K解像度ではLambdaへのオフロードが費用対効果に優れます。
ログで重いフレームを特定する
--log=verboseフラグを使うと、フレームごとのレンダリング進捗がコンソールに出力されます。特定のフレーム範囲だけが異常に遅い場合、そのフレームをframeRangeで切り出してデバッグできます。
# CLIから詳細ログを有効化してどのフレームが重いかを確認する
npx remotion render MyComposition out/video.mp4 --log=verbose
# 問題フレームだけを再レンダリングして原因を切り分ける
npx remotion render MyComposition out/debug.mp4 --frames=150-180
renderMedia()APIからはonFrameUpdateコールバックでレンダリング済みフレーム数を取得できます。時刻を記録すれば、フレームあたりの処理時間の推移も計測できます。
let lastFrameTime = Date.now();
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "h264",
outputLocation: "out/video.mp4",
onFrameUpdate: (frame) => {
const now = Date.now();
const elapsed = now - lastFrameTime;
lastFrameTime = now;
// 特定フレームで処理時間が急増していればそのフレームで重い処理が走っている
if (elapsed > 1000) {
console.warn(`Frame ${frame} took ${elapsed}ms`);
}
},
});
まとめ:最適化の優先順位
レンダリング時間の最適化は、影響の大きい順に取り組むと効率的です。
- コンポジションの複雑さを下げます。
<OffthreadVideo>への切り替え、重い計算のコンポーネント外への移動、不要なCSSフィルタの削除が主な対象です。 - 透過不要なコンポジションで
imageFormat: "png"を使っているなら、jpegに切り替えます。 concurrencyはスワップが発生しない範囲でメモリ上限まで引き上げます。- 解像度は用途に合わせます。確認・プレビューは480pか720p、本番のみフルHD以上です。
- 長尺・高解像度になったらLambdaに移行します。ローカルの物理限界を超えたらスケールアウトします。
RenderCompカタログのテンプレートは、これらの最適化をあらかじめ適用した状態で設計されています。実際のコンポジション構造を参考にすると、自社テンプレートのパフォーマンスチューニングのヒントが得られるでしょう。
Remotionのレンダリングはアーキテクチャが明確なため、ボトルネックは必ずどこかの変数に帰着します。「なぜ遅いか」を3変数のフレームワークで分析し、測定しながら最適化すれば、無駄なリトライと待機時間を着実に削減できます。