Remotion calculateMetadata:動的尺・非同期データ取得・コンポジション設計の完全ガイド
Remotion calculateMetadata:動的尺・非同期データ取得・コンポジション設計の完全ガイド
Remotionでビデオを作り始めると、最初のうちは <Composition durationInFrames={180} /> のように尺を固定するのが当然に思えます。ロゴアニメーションや定型のイントロ動画なら問題ありません。しかしデータによって構成が変わるコンテンツ——スライド枚数に比例して長さが変わるスライドデッキ、再生リストのトラック合計時間に等しい音楽ビジュアライザー——を作ろうとすると、静的な尺設定はすぐに壁になります。
calculateMetadata はこの問題に対するRemotionの直接の解答です。<Composition> にアタッチする非同期関数で、レンダリング開始前に実行されます。ここでデータを取得し、動的な durationInFrames・寸法・フレームレート・修正済みpropsを返すことができます。
本記事では calculateMetadata のAPI全体を整理し、非同期データ取得のパターン・defaultProps によるスキーマ定義・CLIと renderMedia でのprops上書き・2つの完全実装例(スライドデッキ生成器・プレイリスト動画)を詳しく解説します。
固定尺の限界:なぜ動的な尺が必要か
スライドデッキの動画ジェネレーターを考えてみてください。各スライドを5秒(30fpsで150フレーム)表示するとして、8枚なら1,200フレーム、15枚なら2,250フレームです。
calculateMetadata なしで対応しようとすると選択肢は限られています。
- 最大枚数を想定した長さで固定し、末尾を空白のまま書き出す
- スライド枚数が変わるたびに
durationInFramesを手動で書き換える - レンダリング前にコンポジション設定をパッチするスクリプトを別途用意する
いずれもスケールしません。calculateMetadata を使うと、コンポジション自身がデータを参照して正しい尺を報告します。コードを変えることなく、どんなスライド枚数にも対応します。
calculateMetadata の関数シグネチャ
import { Composition } from "remotion";
<Composition
id="SlideDeck"
component={SlideDeckVideo}
width={1920}
height={1080}
fps={30}
durationInFrames={1} // プレースホルダー — calculateMetadataが上書きする
defaultProps={{
slides: [],
framesPerSlide: 150,
}}
calculateMetadata={async ({ props, defaultProps, compositionId, abortSignal }) => {
return {
durationInFrames: props.slides.length * props.framesPerSlide,
};
}}
/>
関数は次の4フィールドを持つオブジェクトを受け取ります。
props:現在のレンダリング用props。defaultPropsと実行時上書きをマージした結果ですdefaultProps:<Composition>に静的に定義されたデフォルト値。型参照に使いますcompositionId:レンダリング中のコンポジションのID文字列。1つの関数を複数コンポジションで共有する場合に便利ですabortSignal:コンポジションが不要になった(Studioを閉じるなど)場合に発火する標準のAbortSignal。fetch()に渡します
返り値はコンポジション設定の部分オブジェクトです。変更したいフィールドだけを返せばよく、すべてを返す必要はありません。durationInFrames だけ変えたい場合は { durationInFrames: X } のみ返します。
defaultProps はスキーマ定義として機能する
defaultProps はStudioでのプレビューとレンダリングのデフォルトデータを提供するだけでなく、TypeScriptジェネリクスと組み合わせることでpropsの型定義としても機能します。
interface SlideDeckProps {
slides: Array<{
title: string;
body: string;
imageUrl?: string;
}>;
framesPerSlide: number;
accentColor: string;
}
<Composition<SlideDeckProps>
id="SlideDeck"
component={SlideDeckVideo}
width={1920}
height={1080}
fps={30}
durationInFrames={1}
defaultProps={{
slides: [
{ title: "プレビュー", body: "Studioプレビュー用のスライド" },
],
framesPerSlide: 150,
accentColor: "#3a86ff",
}}
calculateMetadata={async ({ props }) => ({
durationInFrames: Math.max(1, props.slides.length) * props.framesPerSlide,
})}
/>
<Composition<SlideDeckProps>> のジェネリクスにより、defaultProps とコンポーネントpropsが同じインターフェースに対して型チェックされます。defaultProps に必須フィールドが足りない場合、コンパイル時にTypeScriptがエラーを出します。
calculateMetadata 内での非同期データ取得
最も強力な機能が async であることです。fetch()・ファイルシステム読み込み・データベースクエリなど任意の非同期操作を呼び出せます。
interface PlaylistProps {
playlistId: string;
tracks: Array<{
title: string;
artist: string;
durationSeconds: number;
}>;
}
<Composition<PlaylistProps>
id="PlaylistVideo"
component={PlaylistVisualiser}
width={1920}
height={1080}
fps={30}
durationInFrames={1}
defaultProps={{
playlistId: "preview",
tracks: [
{ title: "プレビュートラック", artist: "アーティスト名", durationSeconds: 30 },
],
}}
calculateMetadata={async ({ props, abortSignal }) => {
let tracks = props.tracks;
if (props.playlistId !== "preview") {
const response = await fetch(
`https://api.example.com/playlists/${props.playlistId}`,
{ signal: abortSignal }
);
const data = await response.json();
tracks = data.tracks;
}
const totalSeconds = tracks.reduce(
(sum: number, t: { durationSeconds: number }) => sum + t.durationSeconds,
0
);
return {
durationInFrames: Math.round(totalSeconds * 30),
props: {
...props,
tracks, // 取得した実データをpropsとして返す
},
};
}}
/>
重要なポイントは、calculateMetadata から返した props がコンポーネントに渡されるpropsを置き換えることです。ここでpropsを「充実化」できます。スタブデータを実際のAPIデータで置き換えるパターンは頻出します。
レンダリング時にpropsを上書きする
CLIまたは renderMedia でレンダリングするとき、defaultProps を上書きする入力propsを渡せます。これが calculateMetadata に props として届きます。
CLIでの上書き:
npx remotion render SlideDeck --props='{"slides":[{"title":"第1章","body":"内容"},{"title":"第2章","body":"詳細"}],"framesPerSlide":150,"accentColor":"#ff6b6b"}'
JSONファイルで指定:
npx remotion render SlideDeck --props=./render-data/deck-001.json
Node.jsの renderMedia API:
import { renderMedia, selectComposition } from "@remotion/renderer";
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "SlideDeck",
inputProps: {
slides: mySlides,
framesPerSlide: 120,
accentColor: "#7eb8f7",
},
});
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation: "./output/deck.mp4",
});
データフローを整理すると:inputProps → calculateMetadata に props として渡される → calculateMetadata が最終設定(修正済み props 含む)を返す → その設定でレンダリング実行。
完全実装例1:スライドデッキ動画ジェネレーター
コンポーネントとコンポジション登録の完全な実装です。
// SlideDeck.tsx
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from "remotion";
interface Slide {
title: string;
body: string;
}
interface SlideDeckProps {
slides: Slide[];
framesPerSlide: number;
accentColor: string;
}
export const SlideDeckVideo: React.FC<SlideDeckProps> = ({
slides,
framesPerSlide,
accentColor,
}) => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const currentSlideIndex = Math.floor(frame / framesPerSlide);
const frameWithinSlide = frame % framesPerSlide;
const slide = slides[Math.min(currentSlideIndex, slides.length - 1)];
const entryProgress = spring({
frame: frameWithinSlide,
fps: 30,
config: { damping: 20, stiffness: 100, mass: 1 },
});
const exitOpacity = interpolate(
frameWithinSlide,
[framesPerSlide - 20, framesPerSlide - 5],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const titleY = interpolate(entryProgress, [0, 1], [40, 0]);
const entryOpacity = interpolate(entryProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a14",
justifyContent: "center",
alignItems: "flex-start",
padding: `${height * 0.1}px ${width * 0.08}px`,
opacity: exitOpacity,
}}
>
{/* アクセントライン */}
<div
style={{
width: width * 0.05,
height: 5,
backgroundColor: accentColor,
marginBottom: height * 0.04,
opacity: entryOpacity,
}}
/>
{/* スライド番号 */}
<div
style={{
fontFamily: `"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif`,
fontSize: height * 0.022,
color: accentColor,
letterSpacing: "0.12em",
marginBottom: height * 0.025,
opacity: entryOpacity,
}}
>
{currentSlideIndex + 1} / {slides.length}
</div>
{/* タイトル */}
<h1
style={{
fontFamily: `"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif`,
fontSize: height * 0.07,
fontWeight: 700,
color: "#ffffff",
margin: 0,
marginBottom: height * 0.04,
transform: `translateY(${titleY}px)`,
opacity: entryOpacity,
maxWidth: width * 0.72,
lineHeight: 1.3,
}}
>
{slide.title}
</h1>
{/* 本文 */}
<p
style={{
fontFamily: `"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif`,
fontSize: height * 0.03,
color: "#aaaacc",
margin: 0,
opacity: entryOpacity,
maxWidth: width * 0.65,
lineHeight: 1.8,
}}
>
{slide.body}
</p>
</AbsoluteFill>
);
};
Root.tsxでのコンポジション登録:
// Root.tsx
import { Composition } from "remotion";
import { SlideDeckVideo } from "./SlideDeck";
export const RemotionRoot: React.FC = () => (
<Composition
id="SlideDeck"
component={SlideDeckVideo}
width={1920}
height={1080}
fps={30}
durationInFrames={1}
defaultProps={{
slides: [
{ title: "はじめに", body: "Studioプレビュー用のデフォルトスライドです。" },
{ title: "主要テーマ", body: "データドリブンなアプローチを説明します。" },
{ title: "まとめ", body: "次のステップについてお伝えします。" },
],
framesPerSlide: 150,
accentColor: "#3a86ff",
}}
calculateMetadata={async ({ props }) => ({
durationInFrames: props.slides.length * props.framesPerSlide,
})}
/>
);
カスタムデータでレンダリング:
npx remotion render SlideDeck \
--props='{"slides":[{"title":"Q1振り返り","body":"全セグメントで堅調な成長。"},{"title":"Q2目標","body":"3つの新市場への展開。"},{"title":"ありがとうございました","body":"ご質問を歓迎します。"}],"framesPerSlide":180,"accentColor":"#f4a261"}'
出力は540フレーム(3スライド × 180フレーム)になります。手動で尺を計算する必要はありません。
完全実装例2:プレイリスト動画(トラック合計時間から動的計算)
// Root.tsx
import { Composition } from "remotion";
import { PlaylistVisualiser } from "./PlaylistVisualiser";
export const RemotionRoot: React.FC = () => (
<Composition
id="PlaylistVideo"
component={PlaylistVisualiser}
width={1920}
height={1080}
fps={30}
durationInFrames={1}
defaultProps={{
playlistId: "preview",
tracks: [
{ title: "トラック1", artist: "アーティストA", durationSeconds: 45 },
{ title: "トラック2", artist: "アーティストB", durationSeconds: 38 },
],
}}
calculateMetadata={async ({ props, abortSignal }) => {
let resolvedTracks = props.tracks;
if (props.playlistId !== "preview") {
const res = await fetch(
`https://api.example.com/playlists/${props.playlistId}`,
{ signal: abortSignal }
);
const json = await res.json();
resolvedTracks = json.tracks;
}
const totalFrames = resolvedTracks.reduce(
(sum: number, t: { durationSeconds: number }) =>
sum + Math.round(t.durationSeconds * 30),
0
);
return {
durationInFrames: Math.max(1, totalFrames),
props: { ...props, tracks: resolvedTracks },
};
}}
/>
);
動的な寸法変更:アスペクト比をデータに合わせる
width と height も動的に返すことができます。アップロードされた画像の寸法にサムネイルを合わせる場合などに有効です。
calculateMetadata={async ({ props, abortSignal }) => {
const response = await fetch(props.imageUrl, { signal: abortSignal });
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
return {
width: bitmap.width,
height: bitmap.height,
durationInFrames: 90,
};
}}
エラー処理とフォールバック
calculateMetadata が例外をスローすると、RemotionはStudioにエラーを表示し、CLIレンダリングを非ゼロの終了コードで停止します。ネットワーク障害に対してはフォールバックを実装しておくべきです。
calculateMetadata={async ({ props, abortSignal }) => {
try {
const res = await fetch(dataUrl, { signal: abortSignal });
const data = await res.json();
return {
durationInFrames: data.items.length * 90,
props: { ...props, items: data.items },
};
} catch (e) {
console.warn("calculateMetadata: データ取得失敗、デフォルト値を使用", e);
return {
durationInFrames: props.items.length * 90,
};
}
}}
RenderCompの動的コンポジションテンプレート
calculateMetadata のアボートシグナル処理・エラーバウンダリ・非同期境界をまたいだ型安全・CLIオーバーライドワークフローを最初から正しく実装するのは時間がかかります。RenderComp(rendercomp.com)では、本番品質の calculateMetadata 統合が組み込み済みのRemotionテンプレートを提供しています。スライドデッキジェネレーター・データバインド型チャート動画・音声尺に自動で合わせるポッドキャストエピソードテンプレートなど、配管部分から作る手間なく動画自動化を始められます。
よくある質問
Q1:calculateMetadata内でネットワークリクエストを行うのは安全ですか?
はい。2点注意があります。第一に、abortSignal を fetch() に渡してコンポジションが不要になった時点でリクエストをキャンセルできるようにします。第二に、フェッチ失敗時にレンダリングをブロックしないようエラー処理を実装します。失敗時は defaultProps の値にフォールバックします。
Q2:calculateMetadataはフレームごとに呼ばれますか、それとも1回だけですか? 1回だけです。レンダリング(またはStudioでのコンポジション読み込み)ごとに1回実行され、結果はレンダリングジョブ全体でキャッシュされます。フレーム単位では呼ばれません。
Q3:fpsも動的に変更できますか?
はい。calculateMetadata から { fps: 25 } を返すと、<Composition> の静的な fps を上書きします。PAL向けに25fpsが必要な放送出力で、Studioプレビューは30fpsで作業する場合などに使えます。
Q4:Compositionに設定したdurationInFramesとcalculateMetadataが両方ある場合、どちらが優先されますか?
calculateMetadata の戻り値が優先されます。<Composition> の静的な durationInFrames は calculateMetadata が解決される前にStudioに表示されるプレースホルダーとして機能します。規約として durationInFrames={1} を設定するケースが多いです。
Q5:calculateMetadataでpropsをバリデーションしてエラーをスローできますか?
はい。calculateMetadata 内で Error をスローするとRemotionはそのメッセージをStudioのオーバーレイに表示し、CLIレンダリングは非ゼロ終了コードで停止します。必須環境変数の確認やデータURLの疎通確認など、レンダリングコミット前のバリデーションに適した場所です。
Q6:calculateMetadataはRemotionLambdaでも動作しますか? はい。Remotion Lambdaはローカルレンダラーと完全に同じ動作をします。関数はレンダリング開始前にLambda関数上で実行されます。取得するURLがLambdaのVPC/リージョンからアクセス可能であることを確認してください。
Q7:StudioでcalculateMetadataをデバッグする方法は?
console.log を追加することでブラウザのDevToolsコンソールに出力されます。StudioのCompositionタブで calculateMetadata 実行後に解決された durationInFrames を確認することもできます。