Remotion 修正ラウンドの型安全なバージョン追跡
執筆: RenderComp チーム 編集方針
Remotionにおける動画は、Compositionに渡されたpropsとフレーム番号を引数とする純粋な関数です。同じpropsを与えれば、レンダリング環境が違っていてもまったく同じフレームシーケンスが生成されます。この性質が、クライアント案件で避けられない「修正ラウンド管理」の問題を解くための鍵です。
クライアントからの修正依頼は、テキストの変更、カラーパレットの調整、タイミングの微修正など、propsの値として表現できるものがほとんどです。各ラウンドのpropsをJSONとして記録しておけば、「R2で承認されたテキストに戻したい」「R1とR3のどこが変わったか確認したい」という要求に、コードで応答できます。
この記事では、RevisionManifestという型付きのスナップショット構造を設計し、差分比較・再レンダリング・プレビューオーバーレイまでを実装する一連の手順を示します。RemotionのrenderMedia()とTypeScriptの型システムを組み合わせることで、修正台帳の代わりにコードが変更履歴の唯一の情報源になります。
RevisionManifest の型設計
まず、修正ラウンドを表す型を定義します。RevisionRoundが1回の修正サイクルに対応し、RevisionManifestがラウンドの配列をCompositionと紐付けて保持します。
durationInFramesとfpsをラウンドレベルに持たせているのは重要な設計判断です。修正の途中でCompositionのデフォルト値が変わっても、スナップショット済みのラウンドを再レンダリングしたときの結果が変わらないことを保証するためです。
// src/types/revision.ts
export type RevisionRound = {
round: number; // 1始まりの連番(R1, R2, R3...)
label: string; // "初稿" | "修正2" | "最終確認済み" など
snapshotAt: string; // ISO 8601 形式のタイムスタンプ
props: Record<string, unknown>; // Composition に渡す全 inputProps
durationInFrames: number;
fps: number;
gitRef?: string; // オプション: git commit hash またはタグ
};
export type RevisionManifest = {
compositionId: string;
projectId: string; // クライアント識別子
rounds: RevisionRound[];
};
propsフィールドをRecord<string, unknown>としているのは、CompositionごとにinputPropsの形が異なるためです。型の安全性は、後述の差分比較ユーティリティでジェネリクスを使って取り戻します。
ラウンドのスナップショット記録
修正ラウンドの記録はsnapshotRevision()で実行します。マニフェストファイルが存在しない場合は新規作成し、存在する場合はrounds配列に追記します。gitが利用可能な環境では、コミットハッシュも合わせて保存します。
// scripts/snapshot-revision.ts
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import type { RevisionManifest, RevisionRound } from "../src/types/revision";
export async function snapshotRevision(opts: {
compositionId: string;
projectId: string;
props: Record<string, unknown>;
durationInFrames: number;
fps: number;
label: string;
manifestPath: string;
}): Promise<RevisionRound> {
const { compositionId, projectId, props, durationInFrames, fps, label, manifestPath } = opts;
let manifest: RevisionManifest;
if (fs.existsSync(manifestPath)) {
const raw = fs.readFileSync(manifestPath, "utf-8");
manifest = JSON.parse(raw) as RevisionManifest;
} else {
manifest = { compositionId, projectId, rounds: [] };
}
const nextRound = manifest.rounds.length + 1;
let gitRef: string | undefined;
try {
gitRef = execSync("git rev-parse --short HEAD", {
stdio: ["pipe", "pipe", "ignore"],
})
.toString()
.trim();
} catch {
// git 管理外のプロジェクトでは省略する
}
const round: RevisionRound = {
round: nextRound,
label,
snapshotAt: new Date().toISOString(),
props,
durationInFrames,
fps,
...(gitRef ? { gitRef } : {}),
};
manifest.rounds.push(round);
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
return round;
}
呼び出し側では、Composition固有のpropsを型付きで渡します。durationInFramesの計算式をComposition定義と揃えることが、スナップショットとレンダリング結果の整合性を保つうえで重要です。
// scripts/run-snapshot.ts
import { snapshotRevision } from "./snapshot-revision";
import type { ProductVideoProps } from "../src/compositions/ProductVideo";
const currentProps: ProductVideoProps = {
productName: "Aria Pro",
tagline: "音を、再定義する。",
accentColor: "#3B82F6",
durationSec: 30,
};
await snapshotRevision({
compositionId: "ProductVideo",
projectId: "client-aria-2026",
props: currentProps as Record<string, unknown>,
durationInFrames: currentProps.durationSec * 30, // Composition 定義内と同じ計算式
fps: 30,
label: "修正2",
manifestPath: "./revisions/client-aria-2026.json",
});
ラウンド間の差分比較
2つのRevisionRoundを比較する関数を用意します。トップレベルのキーをJSON.stringifyで比較する実装で、クライアント向けに何が変わったかを一覧で示せます。
// src/utils/diff-rounds.ts
import type { RevisionRound } from "../types/revision";
export type DiffEntry = {
key: string;
from: unknown;
to: unknown;
};
export function diffRounds(a: RevisionRound, b: RevisionRound): DiffEntry[] {
const diffs: DiffEntry[] = [];
const allKeys = new Set([
...Object.keys(a.props),
...Object.keys(b.props),
]);
for (const key of allKeys) {
if (JSON.stringify(a.props[key]) !== JSON.stringify(b.props[key])) {
diffs.push({ key, from: a.props[key], to: b.props[key] });
}
}
if (a.durationInFrames !== b.durationInFrames) {
diffs.push({ key: "__durationInFrames", from: a.durationInFrames, to: b.durationInFrames });
}
if (a.fps !== b.fps) {
diffs.push({ key: "__fps", from: a.fps, to: b.fps });
}
return diffs;
}
R1とR3を比較すると、次のような出力が得られます。
import manifest from "./revisions/client-aria-2026.json";
import { diffRounds } from "./src/utils/diff-rounds";
const r1 = manifest.rounds[0];
const r3 = manifest.rounds[2];
const changes = diffRounds(r1, r3);
// [
// { key: "tagline", from: "体験を、届ける。", to: "音を、再定義する。" },
// { key: "accentColor", from: "#6366F1", to: "#3B82F6" },
// { key: "__durationInFrames", from: 750, to: 900 },
// ]
ネストされたオブジェクトや配列の内部差分は「変更あり」としか表示されません。propsの構造が深い場合は、キーパスを再帰的に辿る処理を追加するか、fast-json-patchのRFC 6902準拠のdiffを組み合わせると精度が上がります。
特定ラウンドの再レンダリング
マニフェストから過去のラウンドを再レンダリングするスクリプトです。@remotion/rendererのrenderMedia()とselectComposition()を使います。selectComposition()はRemotionサーバーのComposition定義からwidth・heightなどのメタデータを取得するため、その後durationInFramesとfpsをスナップショット時の値で上書きします。
// scripts/render-revision.ts
import { renderMedia, selectComposition } from "@remotion/renderer";
import fs from "fs";
import path from "path";
import type { RevisionManifest } from "../src/types/revision";
export async function renderRevision(opts: {
manifestPath: string;
roundNumber: number;
serveUrl: string;
outputDir: string;
}): Promise<string> {
const { manifestPath, roundNumber, serveUrl, outputDir } = opts;
const manifest = JSON.parse(
fs.readFileSync(manifestPath, "utf-8")
) as RevisionManifest;
const round = manifest.rounds.find((r) => r.round === roundNumber);
if (!round) {
throw new Error(`R${roundNumber} はマニフェストに存在しません`);
}
const composition = await selectComposition({
serveUrl,
id: manifest.compositionId,
inputProps: round.props,
});
const paddedRound = String(round.round).padStart(2, "0");
const outputLocation = path.join(outputDir, `R${paddedRound}-${round.label}.mp4`);
fs.mkdirSync(outputDir, { recursive: true });
await renderMedia({
composition: {
...composition,
// Composition 定義のデフォルト値ではなくスナップショット時の値を使う
durationInFrames: round.durationInFrames,
fps: round.fps,
},
serveUrl,
codec: "h264",
outputLocation,
inputProps: round.props,
});
return outputLocation;
}
出力ファイル名をR01-初稿.mp4・R02-修正1.mp4という形式にすることで、ディレクトリを見るだけでラウンドの順序が分かります。padStart(2, "0")でゼロパディングしているのは、ファイルマネージャのアルファベット順ソートでR9がR10の後に来るのを防ぐためです。
プレビューオーバーレイ
Remotion Studioでのプレビュー時に、現在表示しているラウンドをオーバーレイで確認できるようにします。showOverlaypropでオフにでき、本番レンダリング時はこの値をfalseにすることでクリーンな出力を保ちます。
spring()のdurationInFramesに上限を設けているのは、この値を省略するとspringが収束するまで無制限に計算が続き、コンポーネントの挙動が予測しにくくなるためです。30fpsで60フレームは2秒、60fpsなら1秒のアニメーションに対応します。
// src/components/RevisionOverlay.tsx
import React from "react";
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
import type { RevisionRound } from "../types/revision";
type Props = {
revision: Pick<RevisionRound, "round" | "label" | "snapshotAt">;
showOverlay: boolean;
};
export const RevisionOverlay: React.FC<Props> = ({ revision, showOverlay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (!showOverlay) return null;
const opacity = spring({
frame,
fps,
config: {
damping: 26, // この値でオーバーシュートなしに収束する
stiffness: 60,
mass: 1,
},
from: 0,
to: 0.85,
durationInFrames: 60,
});
const date = new Date(revision.snapshotAt).toLocaleDateString("ja-JP", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return (
<AbsoluteFill style={{ pointerEvents: "none" }}>
<div
style={{
position: "absolute",
top: 20,
right: 20,
background: `rgba(15, 15, 15, ${opacity})`,
color: "#F9FAFB",
fontFamily: `"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif`,
fontSize: 13,
lineHeight: 1.6,
padding: "8px 14px",
borderRadius: 6,
borderLeft: "3px solid #3B82F6",
}}
>
<span style={{ opacity: 0.55, fontSize: 11 }}>REV</span>
{" "}
<strong>R{revision.round}</strong>
{" — "}
{revision.label}
<br />
<span style={{ opacity: 0.45, fontSize: 11 }}>{date}</span>
</div>
</AbsoluteFill>
);
};
Compositionへの組み込みでは、revisionpropをオプショナルにしておくことで、既存のCompositionに後から追加しやすくなります。
// src/compositions/ProductVideo.tsx
import React from "react";
import { AbsoluteFill, Sequence, useVideoConfig } from "remotion";
import { RevisionOverlay } from "../components/RevisionOverlay";
import type { RevisionRound } from "../types/revision";
export type ProductVideoProps = {
productName: string;
tagline: string;
accentColor: string;
durationSec: number;
revision?: Pick<RevisionRound, "round" | "label" | "snapshotAt">;
showRevisionOverlay?: boolean;
};
export const ProductVideo: React.FC<ProductVideoProps> = ({
productName,
tagline,
accentColor,
revision,
showRevisionOverlay = false,
}) => {
const { durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ background: "#0F172A" }}>
<Sequence from={0} durationInFrames={durationInFrames}>
{/* メインコンテンツ */}
</Sequence>
{revision && (
<RevisionOverlay revision={revision} showOverlay={showRevisionOverlay} />
)}
</AbsoluteFill>
);
};
スキーマ検証
プロジェクトが長期化すると、マニフェストファイルが手動で編集されてスキーマから外れることがあります。zodを使った検証をスナップショット読み込み時に挟むことで、問題を早期に発見できます。
// src/types/revision.ts(zod スキーマ追加版)
import { z } from "zod";
export const RevisionRoundSchema = z.object({
round: z.number().int().positive(),
label: z.string().min(1),
snapshotAt: z.string().datetime(),
props: z.record(z.unknown()),
durationInFrames: z.number().int().positive(),
fps: z.number().positive(),
gitRef: z.string().optional(),
});
export const RevisionManifestSchema = z.object({
compositionId: z.string().min(1),
projectId: z.string().min(1),
rounds: z.array(RevisionRoundSchema),
});
export type RevisionRound = z.infer<typeof RevisionRoundSchema>;
export type RevisionManifest = z.infer<typeof RevisionManifestSchema>;
renderRevision()の読み込み部分を次のように差し替えると、不正なマニフェストでのレンダリング試行を防げます。
// scripts/render-revision.ts(検証追加)
import { RevisionManifestSchema } from "../src/types/revision";
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
const manifest = RevisionManifestSchema.parse(raw); // 不正な場合はここで throw
z.string().datetime()はISO 8601形式のみを受け付けるため、snapshotAtフィールドへの手動入力ミスも検出できます。
まとめ
RevisionManifestの型設計からsnapshotRevision()・diffRounds()・renderRevision()の実装まで、クライアント修正ラウンドの追跡に必要な構成要素を示しました。
各ラウンドのpropsをJSONとして記録することで、任意の時点への再レンダリングがrenderMedia()の呼び出しとして表現できます。差分比較もJavaScriptの等値比較に還元されるため、外部ツールを追加せずに機能します。zodによるスキーマ検証を入れることで、マニフェストの手動編集に起因するエラーを早い段階で検出できます。
RenderCompのカタログに収録されているテンプレートは、inputPropsを型付きで定義しているため、revisionpropを追加するだけでこの仕組みを導入できます。修正ラウンドが積み重なるにつれて、マニフェストはその案件の変更履歴として機能します。gitRefフィールドにコミットハッシュを記録しておけば、ソースコードの変更と動画の変更を後から対応付けることも可能です。