Remotionテンプレートの日本語化で壊れた6つの実装バグ
執筆: RenderComp チーム 編集方針
Remotionの設計思想の中心にあるのは「ビデオはフレームごとの決定論的な計算である」という原則です。Reactコンポーネントはフレーム番号を入力として受け取り、そのフレームの見た目を決定します。副作用も、状態管理も、タイマーもありません。フレーム42でレイアウトが崩れていれば、レンダリングされた全コピーで同じように崩れます。
この決定論的な性質は、ローカライズのバグを特に手厳しいものにします。Webアプリであればブラウザが実行時にリフローして帳尻を合わせることもあります。しかしRemotionでは、フレーム42でテキストがコンテナからはみ出しているなら、そのフレームはその見た目のまま動画ファイルに焼き込まれます。後から実行時に修正する手段はありません。
今回は、英語向けに制作したテンプレートを日本語にローカライズした際に実際に遭遇した6つの実装バグをケーススタディ形式で解説します。それぞれのバグがなぜ起きたか、そしてRemotionのAPIを使ってどう修正したかを、動作するコードとともに紹介します。
前提:対象テンプレートの構成
対象テンプレートは、製品のハイライトを紹介する30秒(30fps・900フレーム)の動画です。主な機能はテキストのタイプライター風アニメーション、数値カウンターアップ、字幕オーバーレイの3つです。英語コンテンツでは問題なく動作していたものを、日本語に差し替えた瞬間から問題が連鎖しました。
バグ1:テキストオーバーフロー(ハードコードされた幅の仮定)
英語版の見出しコンポーネントは次のような実装でした。
const HeadingText: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { width } = useVideoConfig();
const opacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
fontSize: 72,
fontWeight: 700,
color: '#fff',
width: width * 0.6, // 英語の平均文字幅を前提に全幅の60%を確保
textAlign: 'center',
opacity,
}}
>
{text}
</div>
</AbsoluteFill>
);
};
英語テキスト "Transform Your Workflow" は2行に収まり、視覚的に問題はありませんでした。しかし日本語テキスト "あなたのワークフローを根本から変革する" では、全角文字(1文字あたりの幅がラテン文字の約2倍)により行数が増え、レイアウト全体が崩れました。
テキストブロックの高さが変わることで justifyContent: 'center' による縦方向の配置も視覚的にズレました。Reactはコンポーネントを正しく中央配置しますが、文字の物理的な重心がテキストブロックの高さに依存するため、行数が変わると「なんとなくズレた」印象になります。
修正:コンテンツと言語に基づいた幅の調整
const HeadingText: React.FC<{ text: string; lang: 'en' | 'ja' }> = ({
text,
lang,
}) => {
const frame = useCurrentFrame();
const { width } = useVideoConfig();
// 全角文字が主体の日本語は広いコンテナを割り当てる
const containerWidth = lang === 'ja' ? width * 0.78 : width * 0.6;
const opacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
fontSize: 72,
fontWeight: 700,
color: '#fff',
width: containerWidth,
textAlign: 'center',
// 日本語は文字境界で折り返せる(禁則処理は別途対応)
wordBreak: lang === 'ja' ? 'break-all' : 'keep-all',
// 日本語フォントはラテン書体より行間を広くとる
lineHeight: lang === 'ja' ? 1.6 : 1.2,
opacity,
}}
>
{text}
</div>
</AbsoluteFill>
);
};
バグ2:禁則処理の違反
wordBreak: 'break-all' を設定すると日本語は折り返せるようになりますが、新たな問題が出ました。。(句点)や 」(閉じ括弧)が行頭に来てしまう禁則処理の違反です。
CSSには line-break: strict が用意されており、行頭・行末禁止文字を厳密に扱います。Remotionのレンダリング環境はChromiumベースのため、この値は有効に機能します。
// 日本語テキストの禁則処理に対応したスタイル定義
const japaneseSafeStyle: React.CSSProperties = {
wordBreak: 'break-all',
lineBreak: 'strict', // 。」など行頭禁止文字を次行に送らない
overflowWrap: 'anywhere', // strict で収まらない極端なケースの最終手段
};
ただし、Chromiumの line-break: strict が日本語の全禁則文字を正確に処理するかは、フォントのグリフデータにも依存します。折り返し位置を細かく制御したい場合は、禁則文字の手前にゼロ幅スペース(\u200B)を挿入し、ブラウザに折り返し位置をヒントとして渡す方法が確実です。これはコンテンツをデータレイヤーで前処理する形になります。
バグ3:タイプライターアニメーションの文字分割ロジック
英語テンプレートでは、タイプライターエフェクトのためにテキストを単語単位で分割していました。英語はスペースで区切られた「単語」という単位が自然です。
// 英語向け:スペース区切りの単語単位
const tokens = text.split(' ');
// "Hello World" → ["Hello", "World"]
日本語にはスペースがありません。text.split(' ') は要素1個の配列を返すため、テキスト全体が一気に出現します。タイプライターエフェクトが機能しなくなりました。
修正:言語に応じたトークン化
// utils/tokenize.ts
// 英語:スペース区切りの単語単位
function tokenizeEn(text: string): string[] {
return text.split(' ');
}
// 日本語:1文字単位(スプレッド構文でUnicodeコードポイント単位に分割)
function tokenizeJa(text: string): string[] {
return [...text];
}
export function tokenize(text: string, lang: 'en' | 'ja'): string[] {
return lang === 'ja' ? tokenizeJa(text) : tokenizeEn(text);
}
Remotionの Sequence コンポーネントを使うと、useCurrentFrame() がシーケンス開始点からの相対フレームを返すため、アニメーション開始フレームをコンポーネント内でハードコードする必要がなくなります。
// components/TypewriterText.tsx
const TypewriterText: React.FC<{
text: string;
lang: 'en' | 'ja';
framesPerToken: number;
}> = ({ text, lang, framesPerToken }) => {
// Sequence内ではframeがシーケンス開始からの相対値になる
const frame = useCurrentFrame();
const tokens = tokenize(text, lang);
const visibleCount = Math.min(
Math.floor(frame / framesPerToken),
tokens.length
);
const visibleText =
lang === 'ja'
? tokens.slice(0, visibleCount).join('') // 日本語:文字をそのまま結合
: tokens.slice(0, visibleCount).join(' '); // 英語:単語間にスペースを戻す
return (
<span
style={{
fontFamily: "'NotoSansJP', 'Hiragino Kaku Gothic ProN', sans-serif",
}}
>
{visibleText}
</span>
);
};
Sequence でヘッディングとサブタイトルを順番に配置する場合、各シーケンスの長さはトークン数から動的に決まります。
// components/TextSequence.tsx
export const TextSequence: React.FC<{ content: VideoContent }> = ({
content,
}) => {
const { lang } = content;
const HEADING_FRAMES_PER_TOKEN = 3; // 1トークンあたり3フレーム(0.1秒 at 30fps)
const SUBTITLE_FRAMES_PER_TOKEN = 2;
const headingTokenCount = tokenize(content.heading, lang).length;
const headingDuration = headingTokenCount * HEADING_FRAMES_PER_TOKEN;
return (
<AbsoluteFill>
{/* ヘッディングは最初から開始 */}
<Sequence from={0} durationInFrames={headingDuration + 30}>
<TypewriterText
text={content.heading}
lang={lang}
framesPerToken={HEADING_FRAMES_PER_TOKEN}
/>
</Sequence>
{/* サブタイトルはヘッディング完了後に開始 */}
<Sequence from={headingDuration}>
<TypewriterText
text={content.subtitle}
lang={lang}
framesPerToken={SUBTITLE_FRAMES_PER_TOKEN}
/>
</Sequence>
</AbsoluteFill>
);
};
バグ4:動画の尺とコンテンツ量の不整合
英語テンプレートは300フレーム(10秒 at 30fps)の固定尺でした。日本語コンテンツに切り替えると、タイプライターアニメーションが終わる前に動画が終了する問題が発生しました。
30文字の日本語見出しを framesPerToken: 3 でアニメーションさせると、それだけで90フレーム(3秒)かかります。サブタイトルを加えると容易に固定尺を超えます。
修正:コンテンツから必要フレームを計算する
// config/duration.ts
export interface VideoContent {
heading: string;
subtitle: string;
lang: 'en' | 'ja';
}
const HEADING_FRAMES_PER_TOKEN = 3;
const SUBTITLE_FRAMES_PER_TOKEN = 2;
const INTRO_FRAMES = 30; // フェードイン:30フレーム(1秒)
const HOLD_FRAMES = 60; // 読了後の余白:60フレーム(2秒)
export function calculateRequiredFrames(content: VideoContent): number {
const { heading, subtitle, lang } = content;
const headingDuration =
tokenize(heading, lang).length * HEADING_FRAMES_PER_TOKEN;
const subtitleDuration =
tokenize(subtitle, lang).length * SUBTITLE_FRAMES_PER_TOKEN;
return INTRO_FRAMES + headingDuration + subtitleDuration + HOLD_FRAMES;
}
// Root.tsx
import { Composition } from 'remotion';
import { calculateRequiredFrames } from './config/duration';
const jaContent: VideoContent = {
heading: 'あなたのワークフローを根本から変革する',
subtitle: 'Remotionで作るプログラマブルなビデオ体験',
lang: 'ja',
};
export const RemotionRoot: React.FC = () => {
// durationInFrames を静的な数値ではなくコンテンツから導出する
const durationInFrames = calculateRequiredFrames(jaContent);
return (
<Composition
id="ProductHighlight"
component={ProductHighlight}
durationInFrames={durationInFrames}
fps={30}
width={1920}
height={1080}
defaultProps={{ content: jaContent }}
/>
);
};
バグ5:レンダリングサーバーでのフォント未インストール
開発環境のmacOSには ヒラギノ角ゴシック がシステムにインストールされているため、プレビューでは日本語テキストが正常に表示されていました。しかしCI/CDパイプライン(Ubuntuベースのコンテナ)でレンダリングすると、日本語グリフを持つフォントがなく、文字が豆腐(□)だらけになりました。
RemotionはレンダリングにヘッドレスのChromiumを使いますが、DockerイメージにはCJKフォントは含まれていません。
修正:woff2フォントを staticFile() で同梱する
public/
fonts/
noto-sans-jp-400.woff2
noto-sans-jp-700.woff2
// components/GlobalStyles.tsx
import { staticFile } from 'remotion';
// staticFile() はビルド時にパスを解決し、レンダリング環境に正しくバンドルする
export const globalFontCss = `
@font-face {
font-family: 'NotoSansJP';
src: url('${staticFile('fonts/noto-sans-jp-400.woff2')}') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'NotoSansJP';
src: url('${staticFile('fonts/noto-sans-jp-700.woff2')}') format('woff2');
font-weight: 700;
font-style: normal;
}
`;
// 最上位コンポーネントでスタイルを注入
export const ProductHighlight: React.FC<{ content: VideoContent }> = ({
content,
}) => {
return (
<>
<style>{globalFontCss}</style>
<AbsoluteFill
style={{
// システムフォントを fallback として維持する
fontFamily:
"'NotoSansJP', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif",
background: '#0a0a0a',
}}
>
<TextSequence content={content} />
</AbsoluteFill>
</>
);
};
外部CDNへの参照は不要です。staticFile() がRemotionのビルドシステムに対してこのファイルを静的アセットとして扱うよう指示し、レンダリング環境に正しいパスで配置されます。
バグ6:カウンターアップアニメーションの数値フォーマット
製品実績を示すカウンターアップは spring と interpolate を組み合わせた実装でした。
// 英語版カウンター(問題のある実装)
const CounterUp: React.FC<{ target: number; label: string }> = ({
target,
label,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: {
damping: 200, // 高いdampingで数値のバウンスを抑制
stiffness: 80, // ゆっくりと加速するカーブ
mass: 1,
},
});
const value = interpolate(progress, [0, 1], [0, target]);
// 英語向けのカンマ区切り
const formatted = Math.floor(value).toLocaleString('en-US');
return (
<div>
<span style={{ fontSize: 96, fontWeight: 700 }}>{formatted}</span>
<span style={{ fontSize: 36 }}>{label}</span>
</div>
);
};
数値フォーマット自体はロケール間でほぼ同一です。toLocaleString('ja-JP') も3桁カンマ区切りを返します。問題は日本語固有の「万単位」の慣習でした。1,234,567 を日本語コンテキストで表示する場合、123万4567 や 約123万 と表現するほうが自然です。また、label に来る "社" "件" "万円" などの単位は数値の直後に接続する形が正しいため、英語の label を縦積みにするレイアウトでは違和感が生じます。
修正:ロケール対応の数値フォーマットとレイアウト
// utils/formatNumber.ts
export function formatCounter(
value: number,
lang: 'en' | 'ja'
): { integer: string; suffix: string } {
const floor = Math.floor(value);
if (lang === 'ja' && floor >= 10000) {
const man = floor / 10000;
const formatted =
man % 1 === 0 ? `${man}` : `${man.toFixed(1)}`;
return { integer: formatted, suffix: '万' };
}
if (lang === 'ja') {
return { integer: floor.toLocaleString('ja-JP'), suffix: '' };
}
return { integer: floor.toLocaleString('en-US'), suffix: '' };
}
const CounterUp: React.FC<{
target: number;
unit: string; // 日本語:"社" / 英語:"customers"
lang: 'en' | 'ja';
}> = ({ target, unit, lang }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: {
damping: 200,
stiffness: 80,
mass: 1,
},
});
const value = interpolate(progress, [0, 1], [0, target]);
const { integer, suffix } = formatCounter(value, lang);
return (
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: 4,
// 日本語も英語も数値+単位を横並びにする
flexDirection: 'row',
}}
>
<span style={{ fontSize: 96, fontWeight: 700 }}>
{integer}
{suffix}
</span>
<span style={{ fontSize: 36, opacity: 0.75 }}>{unit}</span>
</div>
);
};
まとめ:ローカライズを設計段階に組み込む
このケーススタディから得られる最大の教訓は、ローカライズはテンプレート完成後の後付け作業ではなく、最初から考慮すべきアーキテクチャ上の判断だということです。
今回確認した6つのバグを整理します。
| # | バグ | 根本原因 | 修正パターン |
|---|---|---|---|
| 1 | テキストオーバーフロー | 全角文字の幅をラテン文字と同一視 | lang に応じて containerWidth を調整 |
| 2 | 禁則処理の違反 | wordBreak: break-all のみで対処しようとした | lineBreak: 'strict' を追加 |
| 3 | タイプライター動作不全 | スペース分割が日本語に対応しない | tokenize(text, lang) で分割戦略を切り替え |
| 4 | 動画の尺不足 | durationInFrames を静的な数値でハードコード | トークン数から尺を計算する calculateRequiredFrames |
| 5 | フォント未表示 | CI環境にCJKフォントが未インストール | staticFile() で woff2 フォントを同梱 |
| 6 | 数値フォーマット不自然 | toLocaleString('en-US') 固定 | 万単位対応の formatCounter(value, lang) |
Remotionの useVideoConfig() が返す durationInFrames は、一見すると固定のビデオパラメータに見えます。ローカライズを意識したテンプレートでは、この値はコンテンツの量と言語から導出される変数です。RenderCompカタログのテンプレートはこの考え方を設計の出発点にしており、lang プロップをファーストクラスのパラメータとして扱っています。テンプレートをベースに独自のローカライズを施す際も、本記事のパターンがそのまま参考になるはずです。