R RenderComp
remotion localization typescript video-pipeline rendering

Remotion Multi-Language Rendering: One Composition, N Locales

By RenderComp Team Editorial policy

When a marketing team needs a product video in English, Japanese, Arabic, and German, the traditional workflow is four separate edit sessions. Each locale gets its own project file, its own timeline, its own export. When the motion design changes, all four timelines need updating.

Remotion flips that model. A composition is a pure function of its props and the current frame, which means localization becomes a data problem, not an editing problem. You define one composition, parametrize it with a locale config object, and drive a render loop that emits one MP4 per language. The motion design lives once; the words, duration, font, and text direction change per render.

The tricky parts are the ones the docs gloss over: estimating per-language duration so voiceover has room to breathe, loading a different font per script without touching an external CDN, and handling Arabic’s right-to-left layout inside an AbsoluteFill. This article covers all three, plus the Node.js render loop that ties them together.

The locale props contract

Start by defining what varies across languages. Everything that changes per locale belongs in a single typed props object that both the composition and the render script share.

// src/types.ts
export type TextDirection = 'ltr' | 'rtl';

export interface LocaleConfig {
  lang: string;            // BCP 47 tag: 'en', 'ja', 'ar', 'de'
  headline: string;
  body: string;
  cta: string;
  direction: TextDirection;
  fontFamily: string;      // matches a FontFace family name you will load
  fontUrl: string;         // path relative to your public/ directory
  charsPerSecond: number;  // speaking-rate estimate for this language
}

The charsPerSecond field is where most guides skip the detail. English voiceover averages around 12 to 14 characters per second at a comfortable broadcast pace. Japanese runs around 6 to 7 characters per second: kanji is information-dense, but each character maps to a mora, and native speech hovers near 350 to 420 morae per minute. Arabic is close to English in characters-per-second but varies with vowel diacritics. Hardcode a per-language estimate and expose it as a prop so you can override it without touching composition code:

// src/locales.ts
import type { LocaleConfig } from './types';

export const locales: LocaleConfig[] = [
  {
    lang: 'en',
    headline: 'Ship faster with video.',
    body: 'Build production-grade video components in React.',
    cta: 'Get started',
    direction: 'ltr',
    fontFamily: 'Inter',
    fontUrl: '/fonts/Inter-Regular.woff2',
    charsPerSecond: 13,
  },
  {
    lang: 'ja',
    headline: '動画制作をコードで。',
    body: 'Reactで本番品質の動画コンポーネントを構築する。',
    cta: 'はじめる',
    direction: 'ltr',
    fontFamily: 'NotoSansJP',
    fontUrl: '/fonts/NotoSansJP-Regular.woff2',
    charsPerSecond: 6,
  },
  {
    lang: 'ar',
    headline: 'أطلق مقاطع الفيديو أسرع.',
    body: 'أنشئ مكونات فيديو بمستوى الإنتاج في React.',
    cta: 'ابدأ الآن',
    direction: 'rtl',
    fontFamily: 'NotoSansArabic',
    fontUrl: '/fonts/NotoSansArabic-Regular.woff2',
    charsPerSecond: 12,
  },
];

Dynamic duration with calculateMetadata

Remotion compositions declare a durationInFrames at registration time, but that number can be computed asynchronously via calculateMetadata. This is the mechanism that makes duration a function of your locale config rather than a constant you update by hand between renders.

The calculation takes the total character count of all text in the composition, divides by charsPerSecond, adds fixed padding at each end (headline hold + outro hold), and converts to frames at the composition fps.

// src/compositions/LocalizedPromo.tsx
import { CalculateMetadataFunction } from 'remotion';
import type { LocaleConfig } from '../types';

const TITLE_HOLD_SEC = 1.5;  // frames held on headline before body animates in
const OUTRO_HOLD_SEC = 2.0;  // CTA hold at the end

export const calculateMetadata: CalculateMetadataFunction<LocaleConfig> = ({ props, defaultProps }) => {
  const locale = props ?? defaultProps;
  const totalChars = locale.headline.length + locale.body.length + locale.cta.length;
  const speechSec = totalChars / locale.charsPerSecond;
  const totalSec = TITLE_HOLD_SEC + speechSec + OUTRO_HOLD_SEC;

  // Returning fps here ensures the Studio and the renderer agree,
  // even if the root composition defaultProps were registered with a different value.
  return {
    fps: 30,
    durationInFrames: Math.ceil(totalSec * 30),
  };
};

Wire this into the composition registration in src/Root.tsx:

import { Composition } from 'remotion';
import { LocalizedPromo, calculateMetadata } from './compositions/LocalizedPromo';
import { locales } from './locales';

export const RemotionRoot = () => (
  <Composition
    id="LocalizedPromo"
    component={LocalizedPromo}
    calculateMetadata={calculateMetadata}
    defaultProps={locales[0]}  // Studio previews EN by default
    width={1920}
    height={1080}
  />
);

One subtle point: calculateMetadata receives defaultProps merged with any inputProps supplied at render time. When you invoke renderMedia with a Japanese locale in inputProps, the duration recalculates for that locale, so you never touch the composition file between renders. Japanese copy frequently runs 60 to 80% longer in frames than the English equivalent for the same semantic content, and this makes the dynamic duration mechanism worth the setup cost.

Loading fonts without an external CDN

Each locale uses a different typeface: Inter for Latin scripts, Noto Sans JP for Japanese, Noto Sans Arabic for Arabic. Loading these from any external host introduces a network dependency into your render environment and routes text content through a third-party server. Self-host the woff2 files in public/fonts/ and load them via the FontFace API.

Remotion’s render pipeline is a headless Chromium browser. When delayRender is called, Chromium pauses frame evaluation until continueRender is called with the same handle. The critical rule: call delayRender synchronously at module scope, before the component function ever runs.

// src/compositions/LocalizedPromo.tsx (continued)
import React, { useEffect, useState } from 'react';
import { delayRender, continueRender, useVideoConfig } from 'remotion';
import type { LocaleConfig } from '../types';
import { Scene } from './Scene';

const loadLocaleFont = (family: string, url: string): Promise<void> => {
  const face = new FontFace(family, `url(${url})`);
  return face.load().then((loaded) => {
    document.fonts.add(loaded);
  });
};

// Module-scope: Chromium sees this before the first render tick.
const fontHandle = delayRender('locale-font');

export const LocalizedPromo: React.FC<LocaleConfig> = (props) => {
  const { fps } = useVideoConfig();
  const [fontReady, setFontReady] = useState(false);

  useEffect(() => {
    loadLocaleFont(props.fontFamily, props.fontUrl)
      .then(() => {
        setFontReady(true);
        continueRender(fontHandle);
      })
      .catch(() => {
        // Degrade to system fallback rather than stalling the render forever.
        // Every delayRender must pair with a continueRender — even on failure.
        continueRender(fontHandle);
      });
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [props.fontFamily]);

  if (!fontReady) return null;
  return <Scene {...props} fps={fps} />;
};

Two failure modes to avoid: calling delayRender inside useEffect (Chromium may begin evaluating frames before the effect fires, making the call arrive too late) and returning null without calling continueRender in the catch branch (the render hangs until timeout, which in headless mode defaults to 30 seconds of silence before aborting).

RTL layout inside AbsoluteFill

Arabic and Hebrew require direction: 'rtl' on the container element. In Remotion, AbsoluteFill is a div with position: absolute; inset: 0, so it accepts any CSS style prop. Set direction on the outermost wrapper, and all children inherit the correct inline base direction, so you avoid scattering per-element overrides across the component tree.

// src/compositions/Scene.tsx
import React from 'react';
import {
  AbsoluteFill,
  Sequence,
  useCurrentFrame,
  useVideoConfig,
  spring,
  interpolate,
} from 'remotion';
import type { LocaleConfig } from '../types';

interface SceneProps extends LocaleConfig {
  fps: number;
}

const BODY_START_FRAME = 45;   // 1.5 sec at 30fps — matches TITLE_HOLD_SEC

export const Scene: React.FC<SceneProps> = ({
  direction,
  fontFamily,
  headline,
  body,
  cta,
  fps,
}) => {
  const frame = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();

  const headlineY = spring({
    frame,
    fps,
    from: 24,
    to: 0,
    config: { stiffness: 80, damping: 20, mass: 1 },
  });

  const ctaOpacity = interpolate(
    frame,
    [durationInFrames - 60, durationInFrames - 45],
    [0, 1],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
  );

  return (
    <AbsoluteFill
      style={{
        direction,
        // 'start' tracks direction — no manual left/right switching needed
        textAlign: 'start',
        fontFamily: `${fontFamily}, "Yu Gothic", "Hiragino Kaku Gothic ProN", -apple-system, sans-serif`,
        backgroundColor: '#0a0a0a',
        color: '#f5f5f5',
        padding: '120px 160px',
        boxSizing: 'border-box',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        gap: 32,
      }}
    >
      <h1
        style={{
          fontSize: 72,
          fontWeight: 700,
          lineHeight: 1.1,
          margin: 0,
          transform: `translateY(${headlineY}px)`,
          // CJK displays look better with slight positive tracking;
          // Latin display type reads better slightly tighter.
          letterSpacing: direction === 'ltr' ? '-0.02em' : '0.02em',
        }}
      >
        {headline}
      </h1>

      {/* Sequence delays body and CTA renders — frame 0 inside is always relative */}
      <Sequence from={BODY_START_FRAME}>
        <p
          style={{
            fontSize: 32,
            lineHeight: 1.6,
            margin: 0,
            // Prevents mid-word breaks for English embedded in Japanese while
            // still allowing ideographic line-break anywhere.
            overflowWrap: 'break-word',
            wordBreak: 'keep-all',
          }}
        >
          {body}
        </p>
      </Sequence>

      <button
        style={{
          // In LTF flex, flex-start = left edge. In RTL, flex-start = right edge.
          // Neither is what we want for both — pin to the text-start side explicitly.
          alignSelf: direction === 'rtl' ? 'flex-end' : 'flex-start',
          fontSize: 24,
          padding: '16px 40px',
          borderRadius: 8,
          border: 'none',
          backgroundColor: '#4f46e5',
          color: '#fff',
          opacity: ctaOpacity,
          cursor: 'default',
        }}
      >
        {cta}
      </button>
    </AbsoluteFill>
  );
};

The wordBreak: 'keep-all' choice matters in mixed-script text. 'break-all' causes English proper nouns embedded in a Japanese sentence to wrap at arbitrary character positions, which is visually wrong. 'keep-all' leaves English words intact while still allowing ideographic line breaks between CJK characters.

The alignSelf on the CTA button is a flex-direction trap. In an RTL flex container, flex-start is the right edge, which is the text-start side visually but the wrong semantic. Mirroring to flex-end keeps the button pinned to the start of the reading direction in both LTR and RTL layouts.

The Node.js render loop

The @remotion/renderer package exposes selectComposition and renderMedia as async Node.js functions. selectComposition executes calculateMetadata for a given set of inputProps and returns a composition object with the correct durationInFrames for that locale, before renderMedia ever opens a browser.

// scripts/render-all-locales.ts
import { selectComposition, renderMedia } from '@remotion/renderer';
import { locales } from '../src/locales';
import path from 'path';

const SERVE_URL = 'http://localhost:3001'; // output of `npx remotion serve`
const COMPOSITION_ID = 'LocalizedPromo';
const OUT_DIR = path.resolve('out');

const renderLocale = async (locale: (typeof locales)[number]): Promise<void> => {
  // calculateMetadata runs here — composition.durationInFrames is locale-specific
  const composition = await selectComposition({
    serveUrl: SERVE_URL,
    id: COMPOSITION_ID,
    inputProps: locale,
  });

  const outputLocation = path.join(OUT_DIR, `promo-${locale.lang}.mp4`);

  await renderMedia({
    composition,
    serveUrl: SERVE_URL,
    codec: 'h264',
    outputLocation,
    inputProps: locale,
    onProgress: ({ progress }) => {
      process.stdout.write(`\r[${locale.lang}] ${Math.round(progress * 100)}%`.padEnd(24));
    },
    chromiumOptions: {
      // Required in headless CI environments without a real GPU
      gl: 'swiftshader',
    },
  });

  process.stdout.write(`\n[${locale.lang}] → ${outputLocation}\n`);
};

Controlling concurrency

renderMedia spawns its own internal pool of browser tabs (tunable via concurrencyPerTab), but the outer locale loop runs sequentially by default. A simple semaphore lets you run two or three locales at once without spawning all N simultaneously:

// scripts/render-all-locales.ts (continued)
const withConcurrency = <T>(
  tasks: Array<() => Promise<T>>,
  limit: number,
): Promise<T[]> => {
  const results: Promise<T>[] = [];
  let index = 0;

  const runNext = async (): Promise<void> => {
    if (index >= tasks.length) return;
    const i = index++;
    results[i] = tasks[i]();
    await results[i];
    await runNext();
  };

  return Promise.all(Array.from({ length: limit }, runNext)).then(() =>
    Promise.all(results),
  );
};

const main = async () => {
  const tasks = locales.map((locale) => () => renderLocale(locale));
  // Each Remotion render peaks at ~600–900 MB depending on composition complexity.
  // On an 8 GB render machine, 2 concurrent locales is the practical safe ceiling.
  await withConcurrency(tasks, 2);
  console.log('\nAll locales complete.');
};

main().catch(console.error);

Pointing to bundled font assets

When fontUrl is /fonts/NotoSansJP-Regular.woff2, Remotion’s dev server resolves it against your public/ directory. In the render script, the same URL applies: the headless browser fetches http://localhost:3001/fonts/NotoSansJP-Regular.woff2. If you use bundle() to pre-bundle the composition and render without a dev server, Remotion’s bundler copies everything from public/ into the bundle output and the path continues to resolve correctly, with no special handling required as long as fonts live in public/.

Wrapping up

The architecture here rests on one principle: a Remotion composition is a deterministic mapping from (props, frame) → pixels. Locale config is just another set of props. The motion design lives once.

The practical checklist:

  • Put every locale-variable value in a typed props interface. Don’t interpolate locale-specific strings inside the component at definition time.
  • Use calculateMetadata to derive durationInFrames from charsPerSecond. Japanese copy frequently needs 60 to 80% more frames than the English equivalent for the same semantic content.
  • Call delayRender at module scope, not inside a hook. Always pair it with a continueRender in both the success and error branches.
  • Set direction on the outermost container and use textAlign: 'start', which follows the inherited direction automatically, unlike 'left' or 'right'.
  • Use wordBreak: 'keep-all' instead of 'break-all' for mixed-script text that embeds English words in CJK content.
  • Size render concurrency to available RAM, not CPU cores. Each Remotion render is memory-bound: budget roughly 900 MB per concurrent locale on a composition of this complexity.

Templates like those in the RenderComp catalog are built with this kind of pipeline in mind: props-driven, single composition, all variation pushed to render time. The same structure scales from a 15-second social clip to a 5-minute product walkthrough in any number of languages.

Now available

Get 1,000+ Remotion Templates

Pay once — no subscription. Lifetime updates. TypeScript-first.

View pricing →