R RenderComp
remotion typescript localization video-automation pipeline

1つのRemotionコンポジションをN言語対応の動画パイプラインに変える

執筆: RenderComp チーム 編集方針

Remotionが動画をReactコンポーネントとして扱うとき、それは「決定論的な計算」として設計されています。同じpropsを渡せば、同じフレームが返ってくる。この性質は、単純な再利用性を超えた可能性を持っています。1つのコンポジションをN言語分の引数リストに対して順番に実行すれば、N本の動画が生成できます。

多くのチームは、言語ごとにプロジェクトを複製したり、差し替えたいテキストだけを事後スクリプトで書き換えたりしています。Remotionを使えばそのアプローチは根本から変わります。「何をレンダリングするか」はTypeScriptの型として定義され、「いつ・何回レンダリングするか」はNode.jsスクリプトが管理します。コンポジション自体は「言語の切り替えロジック」を持たず、データだけを受け取る構造にしておけば、後から言語を追加するのにコンポジションのコードを一行も変更する必要がありません。

props設計からcalculateMetadataによる言語別尺の自動計算、selectCompositionを使ったパイプライン構築まで、実際に動くコードで解説します。


Composition設計:型安全なpropsで言語非依存な構造を作る

コンポジションが受け取るpropsを型として定義することから始めます。重要なのは、コンポジション自体はどの言語で動作するかを「知らない」設計にすることです。コンテンツは既に翻訳されたテキストとして外から渡されます。

// src/types.ts
export type SupportedLang = 'ja' | 'en' | 'de' | 'fr' | 'ko';

export type HeroVideoProps = {
  lang: SupportedLang;
  headline: string;
  body: string;
  ctaText: string;
  accentColor: string;
};

langフィールドを含めているのは、フォント選択や行間のような「言語に依存する表示上の決定」をコンポジション内で行うためです。コンテンツの翻訳はコンポジションの外側で完結していることが前提で、コンポジション内では翻訳機能を持たせません。

// src/compositions/HeroVideo.tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';
import type { HeroVideoProps } from '../types';
import { FontLoader } from '../components/FontLoader';

const FONT_MAP: Record<SupportedLang, string> = {
  ja: '"NotoSansJP", "Hiragino Kaku Gothic ProN", sans-serif',
  ko: '"NotoSansKR", sans-serif',
  en: '-apple-system, "Segoe UI", Roboto, sans-serif',
  de: '-apple-system, "Segoe UI", Roboto, sans-serif',
  fr: '-apple-system, "Segoe UI", Roboto, sans-serif',
};

export const HeroVideo: React.FC<HeroVideoProps> = ({
  lang,
  headline,
  body,
  accentColor,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // ヘッドラインのフェードイン(stiffness=80はバウンスを最小化した値)
  const headlineOpacity = spring({
    frame,
    fps,
    from: 0,
    to: 1,
    config: { stiffness: 80, damping: 20 },
  });

  // ボディテキストは30フレーム遅れて出現
  const bodyOpacity = interpolate(frame - 30, [0, 20], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // 日本語・韓国語は行間を広めにしないと可読性が下がる
  const lineHeight = lang === 'ja' || lang === 'ko' ? 1.5 : 1.2;

  return (
    <FontLoader lang={lang}>
      <AbsoluteFill
        style={{ background: '#fff', fontFamily: FONT_MAP[lang], padding: '80px 120px' }}
      >
        <h1
          style={{
            fontSize: 72,
            fontWeight: 700,
            color: accentColor,
            opacity: headlineOpacity,
            lineHeight,
          }}
        >
          {headline}
        </h1>
        <p
          style={{
            fontSize: 32,
            marginTop: 40,
            color: '#333',
            opacity: bodyOpacity,
            maxWidth: '75%',
            lineHeight,
          }}
        >
          {body}
        </p>
      </AbsoluteFill>
    </FontLoader>
  );
};

lineHeightをCJK言語と欧文言語で分けているのは組版上の判断です。このような「言語に依存する表示上の調整」をlangから導出することで、コンポジションの外側からはすべてを均一なインターフェイスとして扱えます。


フォント読み込みの罠:delayRendercontinueRender

日本語・韓国語のフォントファイルは数MBを超えることがあります。非同期で読み込む処理が完了する前にRemotionがフレームをキャプチャし始めると、フォールバックフォントでレンダリングされてしまいます。これを防ぐのがdelayRendercontinueRenderのペアです。

// src/components/FontLoader.tsx
import { useEffect, useState } from 'react';
import { continueRender, delayRender, staticFile } from 'remotion';
import type { SupportedLang } from '../types';

// public/fonts/ に配置したセルフホストフォントへのマッピング
const FONT_ASSETS: Partial<Record<SupportedLang, { family: string; file: string }>> = {
  ja: { family: 'NotoSansJP', file: 'NotoSansJP-Regular.woff2' },
  ko: { family: 'NotoSansKR', file: 'NotoSansKR-Regular.woff2' },
};

export const FontLoader: React.FC<{
  lang: SupportedLang;
  children: React.ReactNode;
}> = ({ lang, children }) => {
  // delayRenderはPromiseではなくopaqueなhandleを返す
  // useStateで保持することで、再レンダリングごとに新しいhandleが発行されるのを防ぐ
  const [handle] = useState(() => delayRender(`font:${lang}`));

  useEffect(() => {
    const asset = FONT_ASSETS[lang];
    if (!asset) {
      // システムフォントで対応できる言語はすぐに続行
      continueRender(handle);
      return;
    }

    const style = document.createElement('style');
    style.textContent = `
      @font-face {
        font-family: '${asset.family}';
        src: url('${staticFile(`fonts/${asset.file}`)}') format('woff2');
        font-weight: 400;
        font-display: block;
      }
    `;
    document.head.appendChild(style);

    // document.fonts.loadでブラウザがフォントを実際に使える状態になるまで待機
    document.fonts.load(`16px "${asset.family}"`).then(() => {
      continueRender(handle);
    });
  }, [handle, lang]);

  return <>{children}</>;
};

delayRenderに渡す文字列はラベルとして機能し、タイムアウト(デフォルト30秒)が発生したときのエラーメッセージに表示されます。「どのフォントで詰まっているか」が一目でわかるため、デバッグ時に有用です。フォントが複数の場合は独立したhandleを作成し、すべてのcontinueRenderが呼ばれるまでRemotionはキャプチャを待ちます。


calculateMetadataで言語別に尺を自動計算する

日本語と英語では同じ情報量でも文字数が大きく異なります。また、言語ごとに平均的な読み速度も違います。calculateMetadataを使うと、レンダリング前にpropsを受け取り、動的にdurationInFramesを返せます。

// src/compositions/HeroVideo.meta.ts
import type { CalculateMetadataFunction } from 'remotion';
import type { HeroVideoProps } from '../types';

// 言語別の推定読み速度(1分あたり)
const READING_RATE: Record<string, { unit: 'chars' | 'words'; rate: number }> = {
  ja: { unit: 'chars', rate: 500 }, // 日本語: 約500文字/分
  ko: { unit: 'chars', rate: 450 },
  en: { unit: 'words', rate: 230 }, // 英語: 約230語/分
  de: { unit: 'words', rate: 200 }, // ドイツ語はやや遅め
  fr: { unit: 'words', rate: 220 },
};

function estimateReadFrames(text: string, lang: string, fps: number): number {
  const spec = READING_RATE[lang] ?? READING_RATE['en'];
  const count =
    spec.unit === 'chars'
      ? text.replace(/\s/g, '').length // CJKは空白を除いた文字数で計測
      : text.trim().split(/\s+/).length; // 欧文は単語数で計測
  const minutes = count / spec.rate;
  return Math.ceil(minutes * 60 * fps);
}

export const calculateMetadata: CalculateMetadataFunction<HeroVideoProps> = async ({
  props,
}) => {
  const fps = 30;
  const introFrames = 60; // 2秒のイントロ(headlineのspring完了まで)
  const outroFrames = 45; // 1.5秒のアウトロ
  const bodyFrames = estimateReadFrames(props.body, props.lang, fps);

  return {
    fps,
    durationInFrames: introFrames + bodyFrames + outroFrames,
  };
};

CJK言語で「空白を除いた文字数」を使うのが重要なポイントです。日本語の本文には単語境界がないため、split(/\s+/)では1つの塊にしかなりません。表意文字は1文字ごとに意味を持つため、文字数を読み速度の基準にする方が現実に近い結果になります。

calculateMetadatasrc/index.tsでコンポジションを登録する際に渡します。

// src/index.ts
import { Composition } from 'remotion';
import { HeroVideo } from './compositions/HeroVideo';
import { calculateMetadata } from './compositions/HeroVideo.meta';
import type { HeroVideoProps } from './types';

const defaultProps: HeroVideoProps = {
  lang: 'en',
  headline: 'Video production, in code.',
  body: 'Remotion treats video as components.',
  ctaText: 'Learn more',
  accentColor: '#1a6b3c',
};

export const RemotionRoot: React.FC = () => (
  <Composition
    id="HeroVideo"
    component={HeroVideo}
    defaultProps={defaultProps}
    // calculateMetadataを渡すとdurationInFrames/fpsの静的指定は不要
    calculateMetadata={calculateMetadata}
  />
);

selectCompositionでレンダリング前にメタデータを確定する

getCompositions()はデフォルトのpropsでメタデータを取得しますが、言語別に異なるinputPropsを渡す場合はselectComposition()の方が適切です。この関数はcalculateMetadataを実際のinputPropsで実行し、解決済みの構成を返します。

// pipeline/render-lang.ts
import { selectComposition, renderMedia } from '@remotion/renderer';
import path from 'node:path';
import type { HeroVideoProps } from '../src/types';

export async function renderLang(
  bundleLocation: string,
  props: HeroVideoProps,
): Promise<{ outputFile: string; durationInFrames: number }> {
  // calculateMetadataがinputPropsで実行された解決済みのcompositionを取得
  const composition = await selectComposition({
    serveUrl: bundleLocation,
    id: 'HeroVideo',
    inputProps: props,
  });

  const outputFile = path.resolve(`out/hero-${props.lang}.mp4`);

  await renderMedia({
    composition,
    serveUrl: bundleLocation,
    codec: 'h264',
    outputLocation: outputFile,
    inputProps: props,
    crf: 18, // ビジュアルロスレスに近い品質。SNS配信向けには23程度で十分
    concurrency: 4, // フレームをキャプチャするブラウザタブの数(デフォルトはCPUコア数の半分)
    timeoutInMilliseconds: 60_000, // 1フレームのキャプチャタイムアウト
  });

  return { outputFile, durationInFrames: composition.durationInFrames };
}

selectCompositionを使うとdurationInFramesがその言語の実際のテキスト量に基づいた値になっており、ログ出力やプログレス管理にも使えます。getCompositions()では全コンポジション一覧を取得してfindで絞り込む手間がかかりますが、IDが確定している場合はselectCompositionの方が簡潔です。


パイプライン本体:バンドルを一度だけ作成して全言語を流す

バンドルは重い処理です。N言語分のレンダリングに対して、バンドルはN回ではなく1回だけ実行する設計が必要です。

// pipeline/index.ts
import { bundle } from '@remotion/bundler';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { renderLang } from './render-lang';
import type { HeroVideoProps, SupportedLang } from '../src/types';

function loadScript(lang: SupportedLang): HeroVideoProps {
  const raw = readFileSync(path.resolve(`scripts/${lang}.json`), 'utf-8');
  const parsed = JSON.parse(raw) as Omit<HeroVideoProps, 'lang'>;
  return { lang, ...parsed };
}

async function main() {
  const LANGUAGES: SupportedLang[] = ['en', 'ja', 'de', 'fr', 'ko'];

  console.log('▶ Bundling...');
  const bundleLocation = await bundle({
    entryPoint: path.resolve('./src/index.ts'),
    // 既存のwebpackOverrideがあればここで渡す
  });
  console.log('✓ Bundle ready');

  const results: Array<{
    lang: string;
    outputFile: string;
    durationInFrames: number;
  }> = [];

  // 直列実行:Chromiumプロセス×N言語を並列化するとメモリが枯渇しやすい
  for (const lang of LANGUAGES) {
    const props = loadScript(lang);
    console.log(`▶ [${lang}] ...`);
    const result = await renderLang(bundleLocation, props);
    results.push({ lang, ...result });
    console.log(`✓ [${lang}] → ${result.durationInFrames}f → ${result.outputFile}`);
  }

  console.table(results);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

言語ループを直列にしているのは意図的な判断です。Remotionのレンダリングは内部でChromiumインスタンスを起動し、フレームごとにスクリーンショットを取得します。5言語を並列で動かすとそれぞれが独立したChromiumプロセスを持つため、メモリ使用量が言語数に比例して増加します。concurrencyオプションで1インスタンス内のCPU活用を上げつつ、言語間は直列にするのが安定した構成です。


スクリプトファイルの構造

各言語のコンテンツは独立したJSONファイルで管理します。

// scripts/ja.json
{
  "headline": "映像制作を、コードで。",
  "body": "Remotionはビデオをコンポーネントとして扱います。型安全なpropsで、再現性の高い動画パイプラインを構築できます。",
  "ctaText": "詳しく見る",
  "accentColor": "#1a6b3c"
}
// scripts/en.json
{
  "headline": "Video production, in code.",
  "body": "Remotion treats video as components. Build reproducible video pipelines with type-safe props.",
  "ctaText": "Learn more",
  "accentColor": "#1a6b3c"
}

JSONの構造はOmit<HeroVideoProps, 'lang'>に対応しています。新しい言語を追加するときは、このJSONを1ファイル作成してLANGUAGES配列にコードを追記します。コンポジションのコードには触れません。


Wrapping Up

Remotionで多言語パイプラインを構築するときの要点をまとめます。

コンポジション側でやること

  • propsを型として定義し、コンテンツ・言語コード・スタイル変数をすべてそこから受け取る
  • CJKフォントにはdelayRender/continueRenderで読み込み完了を待機させる
  • calculateMetadatalangと本文量からdurationInFramesを動的に導出する。CJK言語は文字数、欧文は単語数で計測する

パイプライン側でやること

  • bundle()は一度だけ実行してURLを使い回す
  • selectComposition()で言語別に解決済みのメタデータを取得する
  • メモリ制約を考慮して言語ループは基本的に直列実行。フレームレベルの並列化はconcurrencyオプションで行う

この構造を一度作ってしまえば、言語を追加するコストは「JSONを1ファイル書く」だけになります。RenderCompのカタログにあるようなテンプレートも同じ設計思想で作られており、propsを差し替えるだけでコンテンツと言語を切り替えられます。

翻訳済みテキストを生成する仕組み(翻訳APIでも人手のローカライザーでも)がJSONを出力しさえすれば、Remotionは静かに動画を量産します。「翻訳コスト」と「映像制作コスト」を分離できるのが、このアプローチの最大の価値です。

販売中

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

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

料金プランを見る →