Remotionレンダーキューの自己ホスト設計
執筆: RenderComp チーム 編集方針
Remotionのレンダリングは決定論的なフレーム計算です。コンポジションに同じpropsを渡せば同じフレームが生成されるため、失敗したジョブの再実行は本質的に安全です。出力先を同じパスに指定してrenderMediaを呼び直すだけで、正しい結果が得られます。
しかし実際にキューを組むと、この「安全さ」はジョブ状態の管理が正しくなければ崩れます。クラッシュ時に中途半端なmp4ファイルが残ると、次の試行でコーデックが上書きに失敗する場合があります。どのジョブが何回目の試行で、次にいつ実行すべきかを追跡しなければ、失敗ジョブが即時に再試行され続けるか、そのまま放置されます。
ここでは@remotion/rendererを直接呼び出すセルフホスト型キューを実装します。外部サービスへの依存はなく、Node.jsプロセス一つで動きます。ジョブのステート遷移から指数バックオフまで、各コンポーネントを示します。
ジョブの状態モデル
ジョブが取りうる5つの状態を型で定義します。
// types.ts
export type JobStatus =
| 'pending' // 待機中
| 'running' // レンダリング実行中
| 'done' // 正常完了
| 'failed' // リトライ上限に達して終了
| 'retrying'; // バックオフ待ち
export interface RenderJob {
id: string;
compositionId: string;
inputProps: Record<string, unknown>;
outputPath: string;
status: JobStatus;
attempts: number; // これまでの試行回数
maxAttempts: number;
nextRetryAt: number | null; // Unix ms、retrying 時のみ非null
error: string | null;
progress: number; // 0–1
}
retryingとfailedを分けることで、UIや監視スクリプトが「回復待ち」と「完全な失敗」を区別できます。nextRetryAtにタイムスタンプを持たせると、ポーリング間隔を変えることなくバックオフを正確に実装できます。
ジョブストア
インメモリのMapで実装します。永続化が必要になれば、この層だけ差し替えます。
// store.ts
import { randomUUID } from 'node:crypto';
import type { RenderJob } from './types.js';
const jobs = new Map<string, RenderJob>();
export function createJob(
compositionId: string,
inputProps: Record<string, unknown>,
outputPath: string,
maxAttempts = 3
): RenderJob {
const job: RenderJob = {
id: randomUUID(),
compositionId,
inputProps,
outputPath,
status: 'pending',
attempts: 0,
maxAttempts,
nextRetryAt: null,
error: null,
progress: 0,
};
jobs.set(job.id, job);
return job;
}
export function nextPickableJob(): RenderJob | undefined {
const now = Date.now();
for (const job of jobs.values()) {
if (job.status === 'pending') return job;
if (
job.status === 'retrying' &&
job.nextRetryAt !== null &&
job.nextRetryAt <= now
) return job;
}
}
export function updateJob(id: string, patch: Partial<RenderJob>): void {
const existing = jobs.get(id);
if (existing) jobs.set(id, { ...existing, ...patch });
}
export function getAllJobs(): RenderJob[] {
return Array.from(jobs.values());
}
nextPickableJobはpendingと、バックオフが終わったretryingの両方を返します。runningやdoneのジョブは対象外です。この区別により、ワーカーが進行中のジョブを重複して取得することはありません。
エラー分類とリトライ
renderMediaが投げる例外はすべてが一時的ではありません。コンポジションIDが存在しない場合や入力propsの検証失敗は、何度リトライしても結果が変わりません。エラーメッセージのパターンで分類します。
// retry.ts
import { updateJob } from './store.js';
import type { RenderJob } from './types.js';
function isRetryable(err: unknown): boolean {
if (!(err instanceof Error)) return false;
// Chromiumのクラッシュや一時的なプロセス切断
if (err.message.includes('Target closed')) return true;
if (err.message.includes('ENOMEM')) return true;
// コンポジション未定義・props不正は恒久的失敗
if (err.message.includes('Composition with ID')) return false;
if (err.message.includes('inputProps')) return false;
return true;
}
// 試行ごとに倍増: 1s → 2s → 4s(上限30s)
function backoffMs(attempt: number): number {
return Math.min(1000 * 2 ** (attempt - 1), 30_000);
}
export function handleRenderError(job: RenderJob, err: unknown): void {
const message = err instanceof Error ? err.message : String(err);
const exhausted = job.attempts >= job.maxAttempts;
if (!isRetryable(err) || exhausted) {
updateJob(job.id, { status: 'failed', error: message });
return;
}
updateJob(job.id, {
status: 'retrying',
error: message,
nextRetryAt: Date.now() + backoffMs(job.attempts),
});
}
job.attemptsは後述のワーカー内でインクリメント済みの値です。maxAttempts: 3のジョブは3回目の実行後にexhaustedがtrueになり、failedに遷移します。
ワーカー
renderMediaを呼ぶ前にselectCompositionでメタデータを取得する必要があります。renderMediaはコンポジションのフレーム数・FPS・サイズを外部から受け取る設計になっているためです。
// worker.ts
import { selectComposition, renderMedia } from '@remotion/renderer';
import { unlinkSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { updateJob } from './store.js';
import { handleRenderError } from './retry.js';
import type { RenderJob } from './types.js';
const SERVE_URL = process.env.REMOTION_SERVE_URL!;
export async function renderJob(job: RenderJob): Promise<void> {
updateJob(job.id, { status: 'running', attempts: job.attempts + 1 });
mkdirSync(dirname(job.outputPath), { recursive: true });
try {
const composition = await selectComposition({
serveUrl: SERVE_URL,
id: job.compositionId,
inputProps: job.inputProps,
});
await renderMedia({
composition,
serveUrl: SERVE_URL,
codec: 'h264',
outputLocation: job.outputPath,
inputProps: job.inputProps,
onProgress: ({ progress }) => {
updateJob(job.id, { progress });
},
// 1ジョブあたりのChromiumスレッド数。
// 複数ジョブを並列実行するなら concurrency × 並列数 ≤ CPUコア数 に保つ。
concurrency: 2,
});
updateJob(job.id, { status: 'done', progress: 1 });
} catch (err) {
// 部分ファイルを削除してから遷移
try { unlinkSync(job.outputPath); } catch { /* ファイルがない場合は無視 */ }
handleRenderError(job, err);
}
}
concurrency: 2は1ジョブに使うChromiumの並列スレッド数です。デフォルトはロジカルコア数の半分になります。ワーカーを2つ同時に走らせると合計4スレッドになるため、コア数の少ないマシンではconcurrency: 1に調整します。
クラッシュ時に残る部分ファイルをunlinkSyncで削除してからエラー処理に入ることで、次のリトライがクリーンな状態から書き始めます。
ポーリングループ
ジョブを拾うループは500msごとにストアを確認します。MAX_CONCURRENTで同時実行数の上限を設けます。
// queue.ts
import { nextPickableJob } from './store.js';
import { renderJob } from './worker.js';
const MAX_CONCURRENT = 2;
let running = 0;
function tick(): void {
while (running < MAX_CONCURRENT) {
const job = nextPickableJob();
if (!job) break;
running++;
renderJob(job).finally(() => { running--; });
}
}
export function startQueue(intervalMs = 500): NodeJS.Timeout {
return setInterval(tick, intervalMs);
}
tick内でrenderJobをawaitしないことがポイントです。呼び出しごとに空きスロットを埋め、完了した時点でfinallyがスロットを返します。バックオフが1sのとき、intervalMsが500msであれば実際の待機は最大1.5sになります。
状態確認エンドポイント
// api.ts
import { createServer } from 'node:http';
import { getAllJobs } from './store.js';
createServer((req, res) => {
if (req.method === 'GET' && req.url === '/jobs') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(getAllJobs(), null, 2));
} else {
res.writeHead(404).end();
}
}).listen(3001);
/jobsが返すJSONには各ジョブのstatus・progress・attempts・nextRetryAtが含まれます。retrying状態のジョブはnextRetryAtが現在時刻を過ぎると、次のtickでワーカーに渡されます。
無料50本
テンプレート50本を ZIP で受け取る
メールアドレスを入れると、50本の ZIP のリンクをすぐにお送りします。
50本の ZIP をメールで受け取ります。あわせて RenderComp テンプレートの更新と、有料ライブラリを含む製品のご案内を受け取ることに同意します(メールは数通・ワンクリック解除)。 プライバシーポリシー(英語)
メールを使わずに受け取ることもできます。GitHub のリポジトリは公開のままで、登録も不要です。 リポジトリを開く
送信しました
ZIP のリンクを記載したメールをお送りしました。届かない場合は迷惑メールをご確認ください。いますぐ受け取る場合はこちらから直接ダウンロードできます。