タイトル通りです!
YouTubeの文字起こしUIとは下図の右のようなやつです(キングオージャーめちゃくちゃおもしろいです!!!)
まぁそんなに難しくはなく,ただ実装してみた,という感じです.
最終成果物は以下のリンクのようなものです.ここを見れば雰囲気わかるかと思います.
コードはここです.Reactわかる人はコード見たほうが早いと思います.
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
を使います.たぶん一番ベーシックなブラウザでの動画プレイヤーパッケージだと思います.
"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()); }); // (後略)
また,forceUpdateVideoTime
のStateが変わったタイミングで,シークバーを強制変更するところも作ります.
const VideoPlayerView = ({ srcMp4Url, currTimeUpdater, forceUpdateTime, }) => { // (中略) React.useEffect(()=>{ playerRef.current.currentTime(forceUpdateTime); }, [forceUpdateTime]) // (後略)
文字起こし表示コンポーネント
webvtt形式の文字起こしデータは,webvtt-parser
パッケージでパース可能です.
ページを開いたときにFetchしてparseするために,App
コンポーネントで取得してpropで文字起こし表示コンポーネント(TranscriptView
)に渡してます.
文章のリストはMaterial UIのListで作っています.
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属性を使うなどして,再レンダリングを抑えるような実装にする必要がありそうです.