Remotion翻訳テンプレートのテキスト溢れをリリース前に検出
執筆: RenderComp チーム 編集方針
Remotion でビデオをプログラムで生成するとき、各フレームは currentFrame を入力とした純粋な関数です。Web ブラウザであればテキストがコンテナをはみ出してもスクロールバーが現れる、あるいは overflow: auto でレイアウトが再計算されるという「安全弁」が機能します。しかしビデオには再計算の機会がありません。フレームはキャプチャされ、エンコードされ、届けられます。オーバーフローしたテキストは視聴者には永遠に見えないまま出荷されます。
英語テンプレートを日本語に翻訳するとき、あるいは日本語ベースのテンプレートを英語圏向けにローカライズするとき、この問題は特に深刻です。日本語の全角文字は欧文の約 2 倍の幅を占め、文節にスペースがないためブラウザの自動折り返しアルゴリズムも通常とは異なる挙動をします。たとえば “AI-powered automation” という英語フレーズ(20文字)を「AI自動化ソリューション」(12文字)に翻訳すると、文字数は減っているのにレンダリング幅はほぼ同じです。逆方向では、日本語の簡潔な表現を英語に直すと文字数が増え、想定外の行数になることも珍しくありません。
このガイドでは、cancelRender・useLayoutEffect・Canvas API の measureText を組み合わせた翻訳テンプレートの出荷前チェックを段階的に実装します。最終的には @remotion/renderer を使った CI スクリプトまで構築し、問題のある翻訳がメインブランチにマージされる前に自動的に弾けるようにします。
なぜ Remotion のオーバーフローは「静かに失敗する」のか
Remotion の React コンポーネントはヘッドレス Chrome 内でレンダリングされます。CSS の overflow: hidden を指定していなくても、フレームキャプチャの段階でビューポート外の内容は切り取られます。開発中のプレビューで問題に気づきにくい理由が二つあります。一つは <Player> がブラウザウィンドウ内でスケールされており、微妙なはみ出しが視覚的に圧縮されること。もう一つは、開発ビルドと本番レンダラーとでフォントの読み込みタイミングが異なり、フォールバックフォントで表示されているとき幅が実際より短く計測されることです。
手動確認には信頼性がありません。翻訳者がコピーを更新するたびに目で確かめるのはスケールしません。レンダリングパイプラインそのものに検証ロジックを組み込むことが正しいアプローチです。
cancelRender でオーバーフローをビルドエラーに変える
Remotion はレンダリング中に cancelRender(message) を呼び出すと、そのフレームのキャプチャを失敗させエラーメッセージをコンソールに出力します。これを使えば「オーバーフローを検出したらレンダリングを即座に停止する」という強制ゲートが実装できます。
オーバーフロー検出には useLayoutEffect を使います。useLayoutEffect は useEffect よりも前に DOM が計測可能な状態になるためです。チェックは frame === 0 のときだけ行います。テキスト内容がアニメーション中に変化しない場合、最初のフレームで一度計測すれば十分です。字幕やタイトルカードの大多数のケースがこれに該当します。
// src/components/TitleCard.tsx
import { cancelRender, useCurrentFrame, useVideoConfig } from 'remotion';
import { useLayoutEffect, useRef } from 'react';
type Props = {
headline: string;
sub: string;
};
export const TitleCard: React.FC<Props> = ({ headline, sub }) => {
const { width } = useVideoConfig();
const frame = useCurrentFrame();
const headlineRef = useRef<HTMLDivElement>(null);
const subRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (frame !== 0) return;
const check = (el: HTMLDivElement | null, name: string) => {
if (!el) return;
// scrollHeight がコンテナの clientHeight を超えていたらオーバーフロー
// 1px の許容誤差はブラウザの小数 px 丸め対策
if (el.scrollHeight > el.clientHeight + 1) {
cancelRender(
`[TitleCard] "${name}" overflows: ` +
`scrollHeight=${el.scrollHeight}px, clientHeight=${el.clientHeight}px`
);
}
};
check(headlineRef.current, 'headline');
check(subRef.current, 'sub');
}, [frame]);
return (
<div
style={{
width,
padding: '48px 64px',
boxSizing: 'border-box',
background: '#0f172a',
}}
>
{/*
maxHeight を明示することで scrollHeight との比較が有効になる。
overflow: hidden は視覚的に隠すが、cancelRender の方が先に動く。
wordBreak: 'break-all' は日本語混じりテキストでどこでも折り返す。
*/}
<div
ref={headlineRef}
style={{
fontSize: 56,
fontWeight: 700,
color: '#f8fafc',
lineHeight: 1.25,
maxHeight: 56 * 1.25 * 3, // 最大 3 行分 = 210px
overflow: 'hidden',
wordBreak: 'break-all',
}}
>
{headline}
</div>
<div
ref={subRef}
style={{
fontSize: 28,
color: '#94a3b8',
lineHeight: 1.6,
marginTop: 24,
maxHeight: 28 * 1.6 * 2, // 最大 2 行分 = 89.6px
overflow: 'hidden',
wordBreak: 'break-all',
}}
>
{sub}
</div>
</div>
);
};
maxHeight を fontSize × lineHeight × 行数 で計算するのは、CSS の line-height が実際の行ボックスの高さと一致するからです。フォントによっては ascender や descender がこの値を微妙に超えることがあるため、1px の許容誤差を入れています。
フォント読み込みを待ってから計測する
上のパターンには落とし穴があります。レンダラーがフォントをまだ読み込んでいないタイミングで scrollHeight を取得すると、フォールバックフォントのサイズで測定されます。本番フォントでオーバーフローが発生しても、フォールバックフォントで収まっていれば検出できません。
delayRender と continueRender を組み合わせてフォントの準備を待ちます。delayRender を呼ぶと Remotion のレンダラーはフレームキャプチャを一時停止し、対応する continueRender が呼ばれるまで待機します。
// src/components/TitleCard.tsx(フォント待機を追加した版)
import {
cancelRender,
continueRender,
delayRender,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export const TitleCard: React.FC<Props> = ({ headline, sub }) => {
const { width } = useVideoConfig();
const frame = useCurrentFrame();
const headlineRef = useRef<HTMLDivElement>(null);
const subRef = useRef<HTMLDivElement>(null);
// ハンドルはコンポーネント生存中に 1 回だけ作成する
const [handle] = useState(() => delayRender('Waiting for fonts before overflow check'));
useEffect(() => {
// document.fonts.ready はすべての FontFace が resolve された時点で fulfill される
document.fonts.ready.then(() => {
continueRender(handle);
});
}, [handle]);
useLayoutEffect(() => {
// delayRender が保留されている間はこのエフェクトも実行されない
if (frame !== 0) return;
const check = (el: HTMLDivElement | null, name: string) => {
if (!el) return;
if (el.scrollHeight > el.clientHeight + 1) {
cancelRender(`[TitleCard] "${name}" overflows at frame 0`);
}
};
check(headlineRef.current, 'headline');
check(subRef.current, 'sub');
}, [frame, handle]); // handle を deps に含めると eslint exhaustive-deps が通る
// ... JSX は前のサンプルと同じ
};
document.fonts.ready が fulfill されてから continueRender が呼ばれることで、フォント未読み込みの状態でスクリーンショットを撮るという偽陰性を排除できます。
再利用可能な <OverflowGuard> コンポーネント
毎回 useLayoutEffect を書くのは煩雑です。コンテナをラップするだけでオーバーフローを検知できる汎用コンポーネントを用意します。
// src/components/OverflowGuard.tsx
import { cancelRender } from 'remotion';
import { useLayoutEffect, useRef, type ReactNode } from 'react';
type Props = {
id: string; // エラーメッセージ用の識別子
maxHeight: number; // px 単位の許容高さ
children: ReactNode;
style?: React.CSSProperties;
};
export const OverflowGuard: React.FC<Props> = ({
id,
maxHeight,
children,
style,
}) => {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (el.scrollHeight > maxHeight + 1) {
cancelRender(
`[OverflowGuard:${id}] Text overflow detected. ` +
`scrollHeight=${el.scrollHeight}px exceeds maxHeight=${maxHeight}px. ` +
`Check the translated copy.`
);
}
});
return (
<div
ref={ref}
style={{ maxHeight, overflow: 'hidden', ...style }}
>
{children}
</div>
);
};
使い方はシンプルです。
// src/compositions/ProductHighlight.tsx
import { AbsoluteFill } from 'remotion';
import { OverflowGuard } from '../components/OverflowGuard';
type Props = { title: string; body: string };
export const ProductHighlight: React.FC<Props> = ({ title, body }) => {
return (
<AbsoluteFill style={{ background: '#1e293b', padding: 64 }}>
<OverflowGuard id="title" maxHeight={120}>
<h1
style={{
fontSize: 48,
color: '#fff',
margin: 0,
lineHeight: 1.25,
wordBreak: 'break-all',
}}
>
{title}
</h1>
</OverflowGuard>
<OverflowGuard id="body" maxHeight={200} style={{ marginTop: 32 }}>
<p
style={{
fontSize: 24,
color: '#cbd5e1',
lineHeight: 1.65,
margin: 0,
wordBreak: 'break-all',
overflowWrap: 'anywhere',
}}
>
{body}
</p>
</OverflowGuard>
</AbsoluteFill>
);
};
翻訳者がコピーを更新するたびに npx remotion render を通すだけでオーバーフローが検出されます。cancelRender のメッセージにはコンポーネント名・実測値・許容値が含まれるため、ログを読むだけでどこを修正すればよいかが判明します。
Canvas measureText で単行テキストの幅を事前予測する
DOM 計測は「レンダリング後」に判定しますが、Canvas の measureText を使えば「レンダリング前」にテキストの幅を計算できます。折り返しが許されない単行バナーや、ロゴ横テキストのチェックで特に有効です。
measureText はブラウザの CSS レイアウトを介さないため、折り返しが発生しない「理論上の 1 行幅」を返します。フォントが document に読み込まれていることが前提で、未ロードの場合はフォールバックフォントで計測されます。必ず document.fonts.ready の後に呼ぶ必要があります。
// src/utils/measureTextWidth.ts
/**
* Canvas API を使ってテキストの描画幅(px)を返す。
* document.fonts.ready が resolve された後に呼ぶこと。
*/
export function measureTextWidth(
text: string,
fontSizePx: number,
fontFamily: string
): number {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return 0;
// CSS と同じ書式で指定する
ctx.font = `${fontSizePx}px ${fontFamily}`;
return ctx.measureText(text).width;
}
これを SingleLineGuard コンポーネントに組み込みます。
// src/components/SingleLineGuard.tsx
import { cancelRender, continueRender, delayRender } from 'remotion';
import { useEffect, useState, type ReactNode } from 'react';
import { measureTextWidth } from '../utils/measureTextWidth';
type Props = {
text: string;
fontSizePx: number;
fontFamily: string;
maxWidthPx: number;
id: string;
children: ReactNode;
};
export const SingleLineGuard: React.FC<Props> = ({
text,
fontSizePx,
fontFamily,
maxWidthPx,
id,
children,
}) => {
const [handle] = useState(() => delayRender(`SingleLineGuard:${id}`));
useEffect(() => {
document.fonts.ready.then(() => {
const measured = measureTextWidth(text, fontSizePx, fontFamily);
if (measured > maxWidthPx) {
cancelRender(
`[SingleLineGuard:${id}] "${text}" is ${Math.round(measured)}px wide, ` +
`exceeds container ${maxWidthPx}px. ` +
`Consider shorter copy or reduce fontSize below ${fontSizePx}px.`
);
}
continueRender(handle);
});
}, [handle, text, fontSizePx, fontFamily, maxWidthPx, id]);
return <>{children}</>;
};
使用例として、ロゴ横テキストのケースを示します。
// 1080p 動画の左端 640px をロゴ横テキストに割り当てる想定
<SingleLineGuard
id="brand-tagline"
text={tagline}
fontSizePx={32}
fontFamily='"Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif'
maxWidthPx={640}
>
<span style={{ fontSize: 32, whiteSpace: 'nowrap' }}>{tagline}</span>
</SingleLineGuard>
日英混在テキストの CSS 落とし穴
日本語テキストと英語テキストが同一コンポーネントに混在するとき、ブラウザのフォントマッチングアルゴリズムが CJK 文字と Latin 文字に別のフォントを割り当てることがあります。その場合、行ボックスの高さは最も背の高いフォントの ascender と descender で決まります。line-height: 1.5 と指定していても、実効的な行間は 1.5em より広くなる場合があります。
const jaEnMixedStyle: React.CSSProperties = {
fontSize: 36,
// CJK フォントは ascender + descender が欧文より大きい。
// 1.5 以下は行間が詰まって視覚的にも読みにくくなる。
lineHeight: 1.75,
// CJK 内のどこでも折り返す(スペースがない文字列のため必須)
wordBreak: 'break-all',
// URL や長い英単語も収まらない場合は文字途中でも折る
overflowWrap: 'anywhere',
};
wordBreak: 'break-all' だけでは、英語の長い URL がスペースを持つ単語として扱われず折れないことがあります。overflowWrap: 'anywhere' を組み合わせることで、英語ワードはできるだけ単語境界で折り返しつつ、どうしても収まらない場合は文字途中でも折れるという挙動になります。
また、自然な文節単位での改行が重要な日本語ブロックテキストには、BudouX ライブラリを用いて単語分割ヒントを埋め込むことで、word-break: break-all による不自然な分割を避けられます。アニメーションのない静的テキストに特に有効です。
@remotion/renderer を使った CI スクリプト
cancelRender のパターンを全コンポジションに実装したら、次のステップは CI での自動検証です。renderStill はフレーム 0 のスクリーンショットを取得するため、cancelRender が呼ばれると例外を投げます。これを利用してオーバーフローをビルド失敗に変換します。
// scripts/check-overflow.ts
import { renderStill, selectComposition } from '@remotion/renderer';
import path from 'node:path';
const BUNDLE_URL = process.env.REMOTION_SERVE_URL!;
const TEST_CASES = [
{
id: 'TitleCard',
props: {
headline: process.env.HEADLINE_JA ?? 'AIが変えるビジネスの未来',
sub: process.env.SUB_JA ?? '生産性と創造性を同時に高める新しい働き方',
},
},
{
id: 'ProductHighlight',
props: {
title: process.env.TITLE_JA ?? 'プレミアムプラン',
body:
process.env.BODY_JA ??
'すべての機能を制限なく、チーム全員でご利用いただけます。',
},
},
];
async function main() {
let hasError = false;
for (const { id, props } of TEST_CASES) {
try {
const composition = await selectComposition({
serveUrl: BUNDLE_URL,
id,
inputProps: props,
});
await renderStill({
composition,
serveUrl: BUNDLE_URL,
output: path.join('out', `overflow-check-${id.toLowerCase()}.png`),
inputProps: props,
frame: 0,
});
console.log(`✓ ${id}: no overflow`);
} catch (err) {
// cancelRender が呼ばれると renderStill が例外を throw する
console.error(`✗ ${id}: overflow detected`);
console.error((err as Error).message);
hasError = true;
}
}
if (hasError) process.exit(1);
}
main();
GitHub Actions であれば以下のように統合できます。翻訳 PR がオープンされるたびに自動チェックが走ります。
# .github/workflows/overflow-check.yml
- name: Build Remotion bundle
run: npx remotion bundle src/index.ts --out dist/bundle
- name: Run overflow check
env:
REMOTION_SERVE_URL: dist/bundle
HEADLINE_JA: ${{ vars.HEADLINE_JA }}
SUB_JA: ${{ vars.SUB_JA }}
run: npx ts-node scripts/check-overflow.ts
cancelRender のメッセージはそのままエラーログに現れます。どのコンポーネントの何が何px オーバーフローしているかが一目で分かります。
まとめ
翻訳されたビデオテンプレートのテキストオーバーフローが「静かに失敗する」ことが最大のリスクです。Remotion の cancelRender と DOM 計測を組み合わせることで、この失敗をビルドエラーに格上げできます。
実装のチェックリストを整理します。
useLayoutEffect+scrollHeight > clientHeightで折り返し後の高さを計測するdelayRender/continueRenderでフォント読み込みを待ってから計測することで偽陰性を排除する- 単行テキストには Canvas
measureTextで事前幅チェックを追加する <OverflowGuard>を汎用コンポーネントとして整備し、テンプレート全体に適用する- CI で
renderStillの frame 0 を自動検証し、翻訳 PR をゲートする - 日英混在コンテンツでは
wordBreak: 'break-all'+overflowWrap: 'anywhere'を組み合わせる lineHeightは 1.75 以上を確保し、CJK フォントの行ボックスが欧文より高くなることを織り込む
RenderComp のカタログに収録されているテンプレートには OverflowGuard パターンが組み込まれており、翻訳コピーを渡すだけでオーバーフローが自動検出されます。独自テンプレートを構築する場合も、同じパターンをレンダリングパイプラインの一部として組み込むことで、翻訳品質チェックを人手に依存せず機械的に保証できます。
検証をランタイムに埋め込み、翻訳の問題がフレームキャプチャの前に検出されるようにします。