Reactで、動画に字幕(WebVTT)を付けて切り替える方法

​​React​Playwright,TypeScript,WebVTT

動画の字幕は、WebVTT 形式のファイルを <track> 要素で <video> に付けると、ブラウザが映像の上に表示できます(default 属性がなければ、最初は表示されません)。どの字幕を出すかは、video.textTracks の各字幕の mode を showing か disabled にして切り替えます。React では、選んだ言語を state に持ち、useEffect で mode に反映させます。

ここでは、日本語・English・オフを <select> で切り替えるコンポーネントを作ります。あわせて、<select> 以外から字幕が変わったときに <select> の表示を合わせる方法、字幕を映像の外に自分で表示する方法、字幕が出ないときに確かめることを、検証の結果とともに書きます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、ヘッドレスの Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。動画は ffmpeg で作った12秒の検証用ファイル(VP9 の WebM)で、検証用のサーバーもこの環境の中で動かしました。Firefox と Safari、スマートフォンのブラウザでは試していません。ブラウザの操作部品にある字幕メニューからの切り替えは試しておらず、その代わりに mode をコードから書き換えて確かめました。

再生中に「字幕」の select をオフから日本語に変えると映像の下部に重ねて「猫が窓の外を見ています」が出て、続けて English に変えると「A bird has flown in」に変わる。右下に「字幕の切り替え 1回目/2回」のように回数が出る
再生中に、字幕をオフ→日本語→English と切り替える様子(カーソル、回数の表示、ピンクの枠は GIF 用に重ねたもの)

WebVTT の字幕ファイル

字幕ファイルは、先頭に WEBVTT と書き、その後に「表示する時間」と「字幕の文」の組を空行で区切って並べます。検証に使った日本語の字幕は次のとおりです。英語の字幕(en.vtt)も、同じ時間で文だけを英語にしています。

text
WEBVTT

00:00:01.000 --> 00:00:04.000
猫が窓の外を見ています

00:00:05.000 --> 00:00:08.000
鳥が飛んできました

00:00:09.000 --> 00:00:11.500
猫はしっぽを振っています

MDN の WebVTT の形式の説明によると、文字コードは UTF-8、MIME タイプは text/vtt です。先頭の WEBVTT を省いたときにどうなるかは、後の「字幕が出ないとき」の節に書きます。

track 要素を並べ、select で mode を切り替える

SubtitledVideo は、動画の URL と字幕の一覧を受け取り、字幕ごとに <track> を並べます。JSX では、HTML の srclang 属性を srcLang と書きます。

tsx
import { useEffect, useRef, useState } from 'react';

export type Subtitle = { srcLang: string; label: string; src: string };

type Props = { src: string; subtitles: Subtitle[]; crossOrigin?: 'anonymous' };

// 字幕の言語(srcLang)か、字幕なしの 'off'
type Lang = string;

export function SubtitledVideo({ src, subtitles, crossOrigin }: Props) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [lang, setLang] = useState<Lang>('off');

  // 選んだ言語の字幕だけを showing にし、ほかは disabled にする
  useEffect(() => {
    const tracks = videoRef.current!.textTracks;
    for (const track of Array.from(tracks)) {
      track.mode = track.language === lang ? 'showing' : 'disabled';
    }
  }, [lang, subtitles]);

  // ブラウザの字幕メニューなど、ほかの所で mode が変わったら選択を合わせる
  useEffect(() => {
    const tracks = videoRef.current!.textTracks;
    const onChange = () => {
      const showing = Array.from(tracks)
        .filter((t) => t.mode === 'showing')
        .map((t) => t.language);
      // 新しく showing になった字幕を優先する。どれも showing でなければ 'off'
      setLang((prev) => showing.find((l) => l !== prev) ?? (showing.length > 0 ? prev : 'off'));
    };
    tracks.addEventListener('change', onChange);
    return () => tracks.removeEventListener('change', onChange);
  }, []);

  return (
    <div>
      <video ref={videoRef} src={src} controls crossOrigin={crossOrigin} width={640}>
        {subtitles.map((s) => (
          <track key={s.src} kind="subtitles" src={s.src} srcLang={s.srcLang} label={s.label} />
        ))}
      </video>
      <label>
        字幕
        <select value={lang} onChange={(e) => setLang(e.target.value)}>
          <option value="off">オフ</option>
          {subtitles.map((s) => (
            <option key={s.srcLang} value={s.srcLang}>{s.label}</option>
          ))}
        </select>
      </label>
    </div>
  );
}

使う側では、subtitles={[{ srcLang: 'ja', label: '日本語', src: '/ja.vtt' }, { srcLang: 'en', label: 'English', src: '/en.vtt' }]} のように渡します。kind="subtitles" の <track> には srclang が必須だと、MDN の track 要素の説明にあります。コードでは、この srcLang が TextTrack の language になるので、言語で字幕を選んでいます。そのため、このコンポーネントは同じ言語の字幕が2つある場合には対応していません。

切り替えの中心は、最初の useEffect です。TextTrack の mode は disabled(使わない)、hidden(読み込むが表示しない)、showing(映像の上に表示する)の3つで、選んだ言語だけを showing にし、ほかは disabled にします。

字幕ファイルは、選んだときに初めて読み込まれる

<track> に default 属性を付けなければ、mode は最初 disabled です。MDN の説明では、disabled の字幕はファイルを読み込みません。Playwright で <select> を操作し、サーバーに届いたリクエストと各字幕の状態を記録したところ、次のようになりました。

操作 サーバーへのリクエスト 日本語の字幕 英語の字幕
開いた直後 なし disabled、cues は null disabled、cues は null
日本語を選ぶ /ja.vtt showing、cues は3件 disabled、cues は null
English を選ぶ /en.vtt disabled、cues は null showing、cues は3件
オフを選ぶ なし disabled、cues は null disabled、cues は null
もう一度日本語を選ぶ なし showing、cues は3件 disabled、cues は null

ページを開いただけでは字幕ファイルを1つも取りに行かず、選んだ言語のファイルだけが読み込まれました。2回目に日本語を選んだときは、リクエストは出ませんでした。disabled に戻した字幕は、読み込んだ後でも cues が null になります。

このコンポーネントは <track> に default を付けず、どの字幕を出すかを state だけで決めています。なお、MDN の mode の説明には、Safari で独自の操作部品を作るときは default 属性も必要だと書かれています。Safari では試していないので、Safari にも対応させる場合は確かめてください。

select 以外から字幕が変わったときに、表示を合わせる

mode は、<select> 以外からも変えられます。たとえば、ほかのスクリプトが mode を書き換えることがあります。そうして字幕が変わると、<select> の表示と実際の字幕がずれます。これを防ぐのが2つ目の useEffect です。textTracks の change イベントは、字幕の mode が変わると発生するので、そのときに showing の字幕を調べて state に入れます。

最初は、change が届いたら「最初に見つかった showing の字幕」を state に入れる書き方にしていました。日本語を表示している状態で、英語の字幕の mode をコードから showing にしてみると、日本語と英語の両方が showing のまま残り、<select> も日本語のままでした。

原因は、最初に見つかる showing が、もともと表示していた日本語だったことです。setLang('ja') は、いまの state と同じ値です。同じ値で更新したときは、React は再レンダーを省きます。そのため、最初の useEffect も動きません。

そこで、上のコードでは、showing の字幕のうち、いまの state と違う言語を優先して選ぶようにしました。いまの state は、setLang に関数を渡して受け取ります(React の useState の説明にある、前の state をもとに更新する書き方です)。この書き方で同じ操作をすると、次のようになりました。

  • 英語の字幕を showing にした後:英語だけが showing になり、日本語は disabled に戻った。<select> は English になった
  • 続けて英語の字幕を disabled にした後:どちらも disabled になり、<select> はオフになった

state が 'en' に変わったので、最初の useEffect が動いて日本語を disabled にした、という流れです。

字幕を映像の外に、自分で表示する

字幕を映像に重ねず、動画の下に大きく出したいときは、mode を hidden にします。hidden の字幕は、ファイルを読み込んで時間に合わせて cuechange イベントを出しますが、映像の上には何も描きません。いま表示すべき字幕は activeCues に入っているので、それを state に入れて表示します。

tsx
import { useEffect, useRef, useState } from 'react';
import type { Subtitle } from './SubtitledVideo';

// 字幕を video の上には描かせず、video の下に自分で表示する
export function CaptionBelow({ src, subtitle }: { src: string; subtitle: Subtitle }) {
  const trackRef = useRef<HTMLTrackElement>(null);
  const [text, setText] = useState('');

  useEffect(() => {
    const track = trackRef.current!.track;
    track.mode = 'hidden'; // 字幕を読み込むが、video の上には表示しない
    const onCueChange = () => {
      const cues = Array.from(track.activeCues ?? []) as VTTCue[];
      setText(cues.map((c) => c.text).join('\n'));
    };
    track.addEventListener('cuechange', onCueChange);
    return () => track.removeEventListener('cuechange', onCueChange);
  }, []);

  return (
    <div>
      <video src={src} controls width={640}>
        <track ref={trackRef} kind="subtitles" src={subtitle.src} srcLang={subtitle.srcLang} label={subtitle.label} />
      </video>
      <p className="caption" aria-live="polite">{text}</p>
    </div>
  );
}

<track> に ref を付けると、HTMLTrackElement の track プロパティから、その要素の TextTrack を直接取り出せます。textTracks の何番目かを数えずに済みます。

再生しながら1秒ごとに下の表示を記録したところ、1.5秒・2.5秒・3.6秒では「猫が窓の外を見ています」、字幕のない4.6秒では空、5.6秒では「鳥が飛んできました」でした。字幕の時間が終わると cuechange が起き、activeCues が空になるので、表示も消えます。

動画の中には字幕が出ておらず、動画の下の濃い色の帯に「猫が窓の外を見ています」と表示されている
mode を hidden にし、字幕を動画の下に表示したところ(再生位置 2.5秒)

<p> に付けた aria-live="polite" は、表示が変わったことをスクリーンリーダーに知らせるための指定です。スクリーンリーダーでの読み上げは試していません。

字幕が出ないときに確かめること

字幕ファイルの読み込みに失敗すると、<track> で error イベントが起き、readyState が 3(ERROR)になります。エラーは画面に表示されないので、字幕が出ないときは <track> の readyState とブラウザのコンソールを確かめます。次の5つの場合を Chromium で試しました。

試した場合 結果
先頭の WEBVTT の行がないファイル サーバーは 200 を返したが error。readyState は 3、cues は0件
Content-Type: text/plain で返したファイル 読み込めた。cues は3件
別のオリジン(ポートが違うサーバー)の字幕を、crossOrigin なしで読む リクエストが出ずに error。コンソールに「Unsafe attempt to load URL …」
crossOrigin="anonymous" を付け、サーバーが Access-Control-Allow-Origin を返さない error。コンソールに CORS で止められたというエラー
crossOrigin="anonymous" を付け、サーバーが Access-Control-Allow-Origin: * を返す 読み込めた。cues は3件

WEBVTT の行がないファイルは、サーバーが正しく返していても字幕として読めません。リクエスト自体は 200 で成功しているので、通信の記録だけでは原因が分かりません。

字幕ファイルをページと別のオリジン(CDN など)に置くときは、<video> に crossOrigin を付け、字幕のサーバーで CORS を許可する必要があります。MDN の track 要素の説明にも、src は文書と同じオリジンでなければならず、親の <video> に crossorigin 属性があればその限りではない、と書かれています。上の SubtitledVideo が crossOrigin を props で受け取れるようにしているのは、この場合のためです。

text/plain で返したファイルは、検証環境の Chromium では読み込めました。ただ、WebVTT の MIME タイプは text/vtt なので、サーバーの設定では text/vtt を返すようにしておきます。

​Posted by 86