RemotionでCJK字幕の改行を制御する:スペースなし言語の行分割完全ガイド
執筆: RenderComp チーム 編集方針
Remotionがビデオをフレームの決定論的な計算として扱う設計思想は、字幕においても容赦なく適用されます。同じフレーム番号で同じピクセルを生成しなければならない。テキストの折り返し位置が実行環境ごとに変わると、レンダリング結果も変わります。英語のようにスペースで単語が区切られる言語なら、ブラウザのデフォルト動作は概ね一貫しています。しかし日本語・中国語・韓国語(CJK)には単語間のスペースが存在しないため、折り返し位置はブラウザの実装に依存します。
この問題はローカル開発時には見過ごされがちです。macOSのChromeでは意図通りに見えても、Remotionのヘッドレスレンダラー(Puppeteer/Chrome)でフォント描画の微妙な違いから折り返し位置がずれ、字幕が一行オーバーしてセーフゾーンを超える——という事故が現場では頻繁に起きます。30fpsの動画で字幕が1フレームでも意図と異なるレイアウトになれば、それは再現可能なバグです。
このガイドでは、CJK字幕の行分割をRemotionで細かくコントロールする手法を段階的に解説します。CSSプロパティの正確な挙動、Intl.Segmenterを使った文節単位の分割、そして禁則処理(行頭・行末に来てはいけない文字のルール)まで、実際に動くコードで掘り下げます。
なぜデフォルトのCSS折り返しが信頼できないのか
CJKテキストをそのまま<div>に流し込むと、ブラウザはword-break: normalを適用します。このモードでは、CJK文字は任意の文字間で折り返し可能として扱われます。これ自体は間違いではありませんが、問題は禁則処理です。
日本語には行頭禁則文字(、。」』)…など)と行末禁則文字(「『(など)があります。word-break: normalはこれらを考慮しますが、実装はブラウザによって微妙に異なります。さらに致命的なのは、Remotionがレンダリングに使うPuppeteerのChromeバージョンとローカルのChromeバージョンが異なることで、同じCSSでも折り返し結果が変わり得るという点です。
まず問題を再現するシンプルなコンポーネントから始めます。
import { AbsoluteFill, useVideoConfig } from "remotion";
// ❌ 問題のある実装:ブラウザ任せの折り返し
export const BadSubtitle: React.FC<{ text: string }> = ({ text }) => {
const { width } = useVideoConfig();
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
}}
>
<div
style={{
maxWidth: width * 0.85,
fontSize: 48,
color: "#ffffff",
textAlign: "center",
// word-break を指定しないと環境依存になる
}}
>
{text}
</div>
</AbsoluteFill>
);
};
このコンポーネントで「映像制作におけるリモーション活用の最前線」というテキストを表示すると、ローカルとサーバーレンダリングで折り返し位置が異なる場合があります。
CSSプロパティの正確な挙動
word-break の3つの値
word-breakは単語をどこで切るかを制御します。CJK字幕での選択肢を整理します。
word-break: normal(デフォルト)はCJK文字間の任意位置での折り返しを許可し、禁則処理はブラウザ実装に委ねます。本文テキストには概ね適切ですが、字幕の折り返しを細かく制御するには向きません。
word-break: break-allはあらゆる文字間での折り返しを許可し、ラテン文字も含めて禁則処理を無視します。URLや長い英数字を含む字幕では意図しない位置で切れるため、字幕用途では使わないでください。
word-break: keep-allはCJK文字間での自動折り返しを禁止し、折り返しをスペースや句読点の後のみに限定します。日本語字幕では行が非常に長くなる危険があり、意図した使い方ではありません。
// word-break の比較実験
const wordBreakExamples = {
// 「映像制作における」が1行に収まらない場合の挙動を比較
normal: { wordBreak: "normal" as const },
breakAll: { wordBreak: "break-all" as const }, // 禁則無視
keepAll: { wordBreak: "keep-all" as const }, // CJK折り返し禁止
};
line-break プロパティ:禁則の厳密さを制御する
word-breakより重要なのがline-breakです。このプロパティは禁則処理の厳密さを指定します。
/* 選択肢 */
line-break: auto; /* ブラウザが判断(デフォルト) */
line-break: loose; /* 禁則を緩く適用(行が短い場合など) */
line-break: normal; /* 標準的な禁則処理 */
line-break: strict; /* JIS X 4051に準拠した厳格な禁則処理 */
line-break: anywhere; /* 禁則を無視して任意の位置で折り返し */
字幕にはline-break: strictを使いたくなりますが、実は注意が必要です。strictを指定すると、禁則処理を守るために1行が想定より長くなることがあります。例えば「す。」という2文字を行末に置けない状況で前の行に戻すと、その行が容量をオーバーします。
Remotionの字幕コンポーネントで安全に使える組み合わせは次のとおりです。
import { AbsoluteFill, useVideoConfig } from "remotion";
export const SafeSubtitle: React.FC<{ text: string }> = ({ text }) => {
const { width } = useVideoConfig();
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
}}
>
<div
style={{
maxWidth: width * 0.85,
fontSize: 48,
lineHeight: 1.6,
color: "#ffffff",
textAlign: "center",
wordBreak: "normal",
// strict は禁則を守るが行が長くなる可能性あり
// normal はバランスが取れている
lineBreak: "normal",
// overflowWrap は長い英単語やURLへのフォールバック
overflowWrap: "break-word",
}}
>
{text}
</div>
</AbsoluteFill>
);
};
Intl.Segmenter を使った確定的な行分割
CSSに頼る限り、決定論は得られません。Remotionの設計思想を真剣に受け止めるなら、折り返し位置をJavaScriptで計算し、 を明示的に挿入するアプローチが最も確実です。
Intl.Segmenter はECMAScript 2022で標準化されたAPIで、ロケールを考慮した文字列の分割が可能です。Node.js 16以降、現代的なブラウザ、そしてPuppeteerが使うChromiumでサポートされています。
// utils/segmentText.ts
/**
* 日本語テキストを文節(word granularity)で分割する。
* Intl.Segmenter は「ブーケ」「食べ」「ながら」のような
* 形態素レベルの境界を返す。
*/
export function segmentJapanese(text: string): string[] {
const segmenter = new Intl.Segmenter("ja", { granularity: "word" });
const segments: string[] = [];
for (const segment of segmenter.segment(text)) {
segments.push(segment.segment);
}
return segments;
}
/**
* 中国語(简体字)のセグメンタ。
* 中国語は文字レベルの分割が基本になることが多い。
*/
export function segmentChinese(text: string): string[] {
const segmenter = new Intl.Segmenter("zh-Hans", { granularity: "word" });
const segments: string[] = [];
for (const segment of segmenter.segment(text)) {
segments.push(segment.segment);
}
return segments;
}
granularity: "word" を指定すると、isWordLike プロパティで「意味のある単語か」「スペースや句読点か」を区別できます。これを使って、字幕ボックスの幅に収まるように行を組み立てます。
// utils/wrapCJKText.ts
interface SegmentItem {
segment: string;
isWordLike: boolean;
}
/**
* CanvasRenderingContext2D.measureText を使って
* ピクセル幅を実測しながら行を組み立てる。
*
* Remotion の計算ロジック内で呼ぶ場合は、
* offscreen canvas を使う(DOM を汚染しない)。
*/
function measureWidth(text: string, font: string): number {
const canvas = new OffscreenCanvas(1, 1);
const ctx = canvas.getContext("2d")!;
ctx.font = font;
return ctx.measureText(text).width;
}
export function wrapCJKText(
text: string,
locale: "ja" | "zh-Hans" | "ko",
maxWidthPx: number,
// 例: "bold 48px 'Hiragino Kaku Gothic ProN', sans-serif"
font: string
): string {
const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
const segments: SegmentItem[] = [...segmenter.segment(text)].map((s) => ({
segment: s.segment,
isWordLike: s.isWordLike ?? false,
}));
const lines: string[] = [];
let currentLine = "";
for (const item of segments) {
const candidate = currentLine + item.segment;
const candidateWidth = measureWidth(candidate, font);
if (candidateWidth > maxWidthPx && currentLine.length > 0) {
// 現在の行を確定し、新しい行を開始
lines.push(currentLine.trimEnd());
currentLine = item.isWordLike ? item.segment : "";
} else {
currentLine = candidate;
}
}
if (currentLine.length > 0) {
lines.push(currentLine.trimEnd());
}
return lines.join("\n");
}
このユーティリティをRemotionコンポーネントに組み込む際のポイントは、useMemoでメモ化してフレームごとの再計算を防ぐことです。
import { AbsoluteFill, useVideoConfig } from "remotion";
import { useMemo } from "react";
import { wrapCJKText } from "../utils/wrapCJKText";
interface Props {
text: string;
locale?: "ja" | "zh-Hans" | "ko";
fontSize?: number;
}
export const CJKSubtitle: React.FC<Props> = ({
text,
locale = "ja",
fontSize = 48,
}) => {
const { width } = useVideoConfig();
// 字幕の最大幅(動画幅の85%)
const maxWidth = width * 0.85;
// フォント文字列はブラウザとPuppeteerで同じフォントを使う必要がある。
// システムフォントを使うことで外部CDNへの依存を排除する。
const font = `bold ${fontSize}px "Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif`;
// テキストと設定が変わらない限り再計算しない
const wrappedText = useMemo(
() => wrapCJKText(text, locale, maxWidth, font),
[text, locale, maxWidth, font]
);
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
}}
>
<div
style={{
maxWidth,
fontSize,
lineHeight: 1.6,
color: "#ffffff",
textAlign: "center",
// whiteSpace: pre-wrap で \n を改行として描画する
whiteSpace: "pre-wrap",
// 念のためフォールバックも設定
wordBreak: "keep-all",
overflowWrap: "break-word",
fontFamily:
'"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif',
fontWeight: "bold",
}}
>
{wrappedText}
</div>
</AbsoluteFill>
);
};
フェードイン字幕との統合
字幕は静止画ではなく、通常はフェードインやスライドアニメーションを伴います。springとinterpolateを使ったフェードイン実装では、折り返し計算はアニメーション開始前に完了している必要があります。
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
import { useMemo } from "react";
import { wrapCJKText } from "../utils/wrapCJKText";
interface AnimatedCJKSubtitleProps {
text: string;
locale?: "ja" | "zh-Hans" | "ko";
fontSize?: number;
// フェードインのフレーム数(30fps基準で6フレーム = 0.2秒)
fadeInFrames?: number;
}
export const AnimatedCJKSubtitle: React.FC<AnimatedCJKSubtitleProps> = ({
text,
locale = "ja",
fontSize = 48,
fadeInFrames = 6,
}) => {
const frame = useCurrentFrame();
const { width, fps } = useVideoConfig();
const maxWidth = width * 0.85;
const font = `bold ${fontSize}px "Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif`;
const wrappedText = useMemo(
() => wrapCJKText(text, locale, maxWidth, font),
[text, locale, maxWidth, font]
);
// spring を使ったフェードイン
// stiffness: 200 は素早く収束(0.2秒以内に1.0に到達)
// damping: 20 はオーバーシュートなし
const progress = spring({
frame,
fps,
config: {
stiffness: 200,
damping: 20,
mass: 1,
},
durationInFrames: fadeInFrames,
});
const opacity = interpolate(progress, [0, 1], [0, 1]);
// 下から8pxスライドインするトランジション
const translateY = interpolate(progress, [0, 1], [8, 0]);
return (
<AbsoluteFill
style={{
justifyContent: "flex-end",
alignItems: "center",
paddingBottom: 80,
}}
>
<div
style={{
maxWidth,
fontSize,
lineHeight: 1.6,
color: "#ffffff",
textAlign: "center",
whiteSpace: "pre-wrap",
wordBreak: "keep-all",
overflowWrap: "break-word",
fontFamily:
'"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif',
fontWeight: "bold",
opacity,
transform: `translateY(${translateY}px)`,
}}
>
{wrappedText}
</div>
</AbsoluteFill>
);
};
springのdurationInFrames: 6は30fpsで0.2秒に相当します。字幕のフェードインとしては十分素早く、視聴者に字幕の出現を気づかせる最低限の時間です。これより短くすると唐突に見え、長くすると読み始めが遅れます。
混在テキスト(CJK + ラテン文字)の落とし穴
日本語字幕には英単語や数字が頻繁に混在します。「2025年のAI活用事例」のような文字列では、Intl.Segmenterは"ja"ロケールでも英数字を適切に認識します。問題が起きるのは長いURLやアルファベットの長い固有名詞です。
wrapCJKText関数内のoverflowWrap: "break-word"はCSS側のフォールバックとして機能しますが、measureWidthで計算した折り返し位置が英単語の途中になる場合、その単語全体を次の行に送るロジックが必要です。
// utils/wrapCJKText.ts への追記
// 英単語の途中での折り返しを防ぐヘルパー
function isLatinWordChar(char: string): boolean {
return /[a-zA-Z0-9]/.test(char);
}
/**
* 行末が英単語の途中であれば、その単語の先頭まで巻き戻す。
* 例: "2025年のArtificia" → "2025年の" + "Artificia..." に分割
*/
function trimToWordBoundary(line: string): [string, string] {
let i = line.length - 1;
// 行末から遡り、ラテン文字が続く限り巻き戻す
while (i > 0 && isLatinWordChar(line[i])) {
i--;
}
// 巻き戻し後も行末がラテン文字なら(全部ラテン)そのまま返す
if (i === 0) return [line, ""];
// 非ラテン文字の直後で分割
return [line.slice(0, i + 1), line.slice(i + 1)];
}
この処理をwrapCJKTextの行確定ロジックに組み込むことで、英単語の不自然な途中折り返しを防げます。
Sequence を使ったタイムコード同期
実際の動画では字幕は複数のクリップに分かれています。Sequenceコンポーネントと字幕データ配列を組み合わせる標準的なパターンでも、上述の折り返し制御は問題なく機能します。
import { AbsoluteFill, Sequence } from "remotion";
import { CJKSubtitle } from "./CJKSubtitle";
interface SubtitleCue {
startFrame: number;
durationInFrames: number;
text: string;
locale?: "ja" | "zh-Hans" | "ko";
}
interface Props {
cues: SubtitleCue[];
}
export const CJKSubtitleTrack: React.FC<Props> = ({ cues }) => {
return (
<AbsoluteFill>
{cues.map((cue, index) => (
<Sequence
key={index}
from={cue.startFrame}
durationInFrames={cue.durationInFrames}
>
<CJKSubtitle
text={cue.text}
locale={cue.locale ?? "ja"}
/>
</Sequence>
))}
</AbsoluteFill>
);
};
// 使用例(30fps)
const subtitleData: SubtitleCue[] = [
{
startFrame: 0,
durationInFrames: 90, // 3秒
text: "映像制作におけるAI活用の最前線",
locale: "ja",
},
{
startFrame: 90,
durationInFrames: 120, // 4秒
text: "2025年以降、生成AIは映像ワークフローを根本から変えつつあります。",
locale: "ja",
},
];
Wrapping Up
CJK字幕の行分割をRemotionで確定論的に制御するための要点をまとめます。
CSSだけで解決しようとしないでください。word-breakとline-breakはローカル開発では機能しているように見えても、Puppeteerのバージョン差で結果が変わります。CSSはフォールバックとして残しつつ、主制御はJavaScriptで行います。
Intl.Segmenterは現代的な選択肢です。granularity: "word" と isWordLike を組み合わせれば、辞書データを持ち込まずに形態素レベルの分割が可能です。Node.js 16以降とChromium 87以降でサポートされており、Remotionのターゲット環境では問題なく使えます。
OffscreenCanvas.measureText でピクセル幅を実測してください。フォントサイズとmaxWidthから「何文字入るか」を文字数で推測するのは危険です。等幅でないCJKフォントや混在テキストでは誤差が出ます。
whiteSpace: "pre-wrap" と明示的な が最も安全なアプローチです。計算済みの折り返し位置を で挿入し、CSSには折り返しをさせない。Remotionの決定論と最も相性の良い実装です。
英数字混在はtrimToWordBoundaryで保護します。英単語途中の折り返しはCJKコンテキストでも発生します。行確定前に巻き戻しチェックを入れておくと後から後悔しません。
RenderComp カタログのテンプレートのように複数ロケールの字幕を同時にサポートするプロジェクトでは、localeパラメータを外部から注入する設計にしておくと、同じコンポーネントで日本語・中国語・韓国語すべてに対応できます。字幕の折り返しはタイポグラフィの問題である前に、Remotionにおいてはレンダリング再現性の問題です。その視点で実装すれば、環境差によるバグは根本から排除できます。