R RenderComp
remotion render-queue typescript nodejs self-hosted

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でワーカーに渡されます。

販売中

1,000以上のRemotionテンプレートを一括入手

買い切り(一括払い)・サブスクなし・生涯アップデート無料。TypeScript製。

料金プランを見る →

無料50本

テンプレート50本を ZIP で受け取る

メールアドレスを入れると、50本の ZIP のリンクをすぐにお送りします。

50本の ZIP をメールで受け取ります。あわせて RenderComp テンプレートの更新と、有料ライブラリを含む製品のご案内を受け取ることに同意します(メールは数通・ワンクリック解除)。 プライバシーポリシー(英語)

メールを使わずに受け取ることもできます。GitHub のリポジトリは公開のままで、登録も不要です。 リポジトリを開く