R RenderComp
remotion typography cjk subtitles internationalization

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>
  );
};

フェードイン字幕との統合

字幕は静止画ではなく、通常はフェードインやスライドアニメーションを伴います。springinterpolateを使ったフェードイン実装では、折り返し計算はアニメーション開始前に完了している必要があります。

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>
  );
};

springdurationInFrames: 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-breakline-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においてはレンダリング再現性の問題です。その視点で実装すれば、環境差によるバグは根本から排除できます。

販売中

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

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

料金プランを見る →