ひつじTips

技術系いろいろつまみ食います。

YouTubeの文字起こしUIをReactで実装してみる

タイトル通りです!

YouTubeの文字起こしUIとは下図の右のようなやつです(キングオージャーめちゃくちゃおもしろいです!!!)

YouTubeの文字起こしの例(出典: 『「王様戦隊キングオージャー」ヒメノとリタがガールズトークで素顔丸見え!? 変身ポーズ披露&一問一答でわちゃわちゃ!』(oricon))

まぁそんなに難しくはなく,ただ実装してみた,という感じです.

最終成果物は以下のリンクのようなものです.ここを見れば雰囲気わかるかと思います.

https://mu-777.github.io/videojs-transcript-sample/

mu-777.github.io

コードはここです.Reactわかる人はコード見たほうが早いと思います.

github.com

ReactもJavascriptも雰囲気でやってるので,なんかオカシイこと言ってたらすみません.

はじめに

全体構成としては大きく,

の3つで構成されます.

Appの下に,動画再生コンポーネントと文字起こし表示コンポーネントをぶら下げます.コンポーネント間で必要な情報はAppで管理し,propでそれぞれのコンポーネントに渡します.

前提として,動画と文字起こしデータ(vttファイル)は既にあることとします.つまり,自動文字起こしはスコープ外で,あくまでUI部分だけです.

OpenAIのwhisperだとデフォルトで文字起こしデータのvttファイルを吐いてくれるので,そのへんを使ってください~

コンポーネント間での動画再生時間などの情報共有

まず,やりたいこととして

  • 動画の再生時間に合わせて,文字起こしの該当部分をハイライトする
  • 文字起こしの文章をクリックすると,動画のシークバーがその場所に移動する

というのがあり,それを実現するためには,

  • 動画再生コンポーネントで得られる動画再生時間を,文字起こし表示コンポーネントに渡す
    • 文字起こし表示コンポーネントは,そのタイミングでハイライト箇所を変更する
  • 文字起こし表示コンポーネントでクリックされたときに,その場所の時間を動画再生コンポーネントに渡す
    • 動画再生コンポーネントは,そのタイミングで強制的にシークバーをその時間に変更する

が必要です.

ということで,currentVideoTimeのStateと,forceUpdateVideoTimeのStateをAppに用意します.

function App() {

  const [currentVideoTime, setCurrentVideoTime] = React.useState(0);
  const [forceUpdateVideoTime, setForceUpdateVideoTime] = React.useState(0);

// (後略)

https://github.com/mu-777/videojs-transcript-sample/blob/master/src/App.jsx#LL25C1-L36C52

そして,動画再生コンポーネント(VideoPlayerView)と文字起こし表示コンポーネント(TranscriptView)のそれぞれに必要なものをpropで渡します.

function App() {

// (中略)

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Box sx={{ width: '100%', height: '100%' }}>
        <Typography variant="h2">
          Video+Transcript Sample
        </Typography>
        <VideoPlayerView srcMp4Url={srcData.videoPath}
          currTimeUpdater={setCurrentVideoTime}
          forceUpdateTime={forceUpdateVideoTime} />
        <Typography variant="h3" sx={{ textAlign: "left", width: '100%' }}>
          Transcript
        </Typography>
        <TranscriptView webvtt={webvtt}
          currTime={currentVideoTime}
          forceUpdateTimeSetter={setForceUpdateVideoTime} />

// (後略)

https://github.com/mu-777/videojs-transcript-sample/blob/master/src/App.jsx#LL54C1-L64C65

動画再生用のコンポーネント

動画再生にはvideo.jsを使います.たぶん一番ベーシックなブラウザでの動画プレイヤーパッケージだと思います.

videojs.com

"timeupdate"イベントを拾って,動画の再生時間(currentVideoTime)のstateを更新します

const VideoPlayerView = ({
  srcMp4Url,
  currTimeUpdater,
  forceUpdateTime,
}) => {

// (中略)

  React.useEffect(() => {
    if (!playerRef.current) {
      playerRef.current = videojs(videoRef.current, options, () => {
        const player = playerRef.current;
        player.src({ src: srcMp4Url, type: 'video/mp4' });
        player.on("timeupdate", ()=>{
          currTimeUpdater(player.currentTime());
        });

// (後略)

https://github.com/mu-777/videojs-transcript-sample/blob/master/src/VideoPlayerView.jsx#LL27C1-L29C12

また,forceUpdateVideoTimeのStateが変わったタイミングで,シークバーを強制変更するところも作ります.

const VideoPlayerView = ({
  srcMp4Url,
  currTimeUpdater,
  forceUpdateTime,
}) => {

// (中略)

 React.useEffect(()=>{
    playerRef.current.currentTime(forceUpdateTime);
  }, [forceUpdateTime])

// (後略)

https://github.com/mu-777/videojs-transcript-sample/blob/master/src/VideoPlayerView.jsx#LL38C2-L40C24

文字起こし表示コンポーネント

webvtt形式の文字起こしデータは,webvtt-parserパッケージでパース可能です.

ページを開いたときにFetchしてparseするために,Appコンポーネントで取得してpropで文字起こし表示コンポーネント(TranscriptView)に渡してます.

www.npmjs.com

文章のリストはMaterial UIのListで作っています.

mui.com

Listの要素のselected属性を使って,ハイライトをしています.

propからもらう動画の再生時間(currentTime)とwebvttのパース結果のcue.startTimeから,ハイライトするList要素のselectedのTrue/Falseを変えます(すません,ここちょっと問題ありです.詳しくは「さいごに」で).

さらに,リストをクリックしたときにその時間を動画再生コンポーネントに通知するために,onClickでpropからもらうtimeUpdateHandlerを呼び出します.

const TranscriptView = ({
  webvtt,
  currTime,
  forceUpdateTimeSetter,
}) => {

// (中略)

  return (
    <Box sx={{ width: '100%' }}>{
      (!webvtt)
        ? <CircularProgress />
        : <List ref={listRef} sx={{ width: '100%', maxHeight: "500px", overflow: "scroll" }}
          onMouseEnter={() => mouseOnListUpdateHandler(true)}
          onMouseLeave={() => mouseOnListUpdateHandler(false)}
        >
          {webvtt.cues.map((cue, index) => {
            return (
              <ListItemButton
                id={getListItemId(index)} key={index} tabIndex={index} dense={true}
                selected={cue.startTime <= currTime && currTime < ((webvtt.cues[index + 1])?.startTime || currTime + 1)}
                onClick={() => { timeUpdateHandler(cue.startTime) }}
              >

// (後略)

https://github.com/mu-777/videojs-transcript-sample/blob/master/src/TranscriptView.jsx#LL74C1-L78C16

また,Selectedな文章のラインにスクロールするために,どの要素がSelectedかを監視して,それが変更されたタイミングでscrollIntoViewを使って文字起こしリストのスクロールを操作します.

const TranscriptView = ({
  webvtt,
  currTime,
  forceUpdateTimeSetter,
}) => {

// (中略)

  React.useEffect(() => {
    if (listRef.current) {
      const selected = listRef.current.querySelector('.Mui-selected');
      if (selected) {
        setSelectedItemEl(selected);
      }
    }
  }, [currTime]);

  React.useEffect(() => {
    const upOffset = 3;
    if (!isMouseOnList && selectedItemEl && (selectedItemEl.tabIndex > (upOffset - 1))) {
      const focusedIndex = selectedItemEl.tabIndex - (upOffset - 1);
      const focusedItemEl = listRef.current.querySelector(`#${getListItemId(focusedIndex)}`);
      focusedItemEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
  }, [selectedItemEl])

// (後略)

https://github.com/mu-777/videojs-transcript-sample/blob/master/src/TranscriptView.jsx#L48

ただし,文字起こしリストをユーザが操作しているときには勝手にスクロールをいじられると困るので,マウスがリスト上にあるかどうかを監視して,リスト上のときにはスクロールしないようにしています(isMouseOnListフラグ)

さいごに

YouTubeの文字起こしUIの雰囲気は,こんな感じで再現できるんじゃないかなと思います.

ただすみません,この実装だと,動画の再生時間のStateが"timeupdate"のイベントごとに更新されます.つまり,"timeupdate"の周期でReactの再レンダリングが実行されてしまいます.

このサンプルぐらいだと見た目そこまで影響ないですが,もうちょっと複雑になると影響が大きくなる可能性があるので,動画の再生時間をStateで管理せずRef属性を使うなどして,再レンダリングを抑えるような実装にする必要がありそうです.