◆ MediaSource を使って JavaScript で動画ファイルの ArrayBuffer を追加
◆ ファイルを分割して追加できる

最近は期間限定無料動画が色々あったので見ていたのですが 考えてみるとこういうストリーミング動画ってどんな仕組みが使われてるのでしょうか
これまで動画は video タグに mp4 とかの URL を設定するだけでした
ストリーミング再生で HLS とか DASH とか名前くらいは聞いたことはありますが 何をどうやっているのかわかっていません

とりあえず再生中のページで devtools を開いて色々見てみました
動画再生部分は普通に video タグでした
src には blob の URL が設定されています
blob が設定されているものは mp4 ファイルみたいな単純な 1 ファイルじゃないです
なのでその URL を開いて動画ファイル単体として見れませんし コントロールのダウンロードボタンもなくなります

blob URL 自体はこれまで JavaScript にあったものだと URL.createObjectURL に Blob や File 型を渡したときに作られるものです
ですが これはむしろダウンロード用に使うくらいで アクセスすればそのコンテンツが見れます
動画の場合はアクセスしてもファイルが見つかりませんというエラーです

調べてみると URL.createObjectURL には File と Blob 以外に MediaSource も受け取るようです
https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL

いかにもって名前です

MediaSource

MediaSource の使い方を見てみると ArrayBuffer でバイナリデータを追加できるみたいです
適当に拾ってきた動画を使って再生できるか試してみました

<!doctype html>

<meta charset="utf-8" />

<div><span id="placeholder">Now loading...</span></div>

<script>
!async function () {
const video_buffer = await fetch("video.webm").then(e => e.arrayBuffer())

const video = document.createElement("video")
video.controls = true
const media_source = new MediaSource()
video.src = URL.createObjectURL(media_source)

media_source.addEventListener("sourceopen", () => {
const source_buffer = media_source.addSourceBuffer(`video/webm; codecs="vorbis, vp8"`)
source_buffer.addEventListener("updateend", () => media_source.endOfStream())
source_buffer.appendBuffer(video_buffer)
})
placeholder.replaceWith(video)
}()
</script>

問題なく再生できてました

動画は webm 形式だったのでそのまま使ってます
ここでは最初に動画ファイル全体を ArrayBuffer として fetch しています

MediaSource のインスタンスを作って createObjectURL で URL を作って video 要素の src に設定します
今ではまだ未対応ですが将来的には MediaSource を直接 video の srcObject プロパティにセットできるようになるそうです

video.srcObject = media_source

準備ができたら sourceopen イベントが起きるので そこで MediaSource のインスタンスに SourceBuffer を追加します
イベントを待たずに追加するとエラーでした

追加時には addSourceBuffer メソッドの引数に メディアの mime type が必要です
video/mp4 や video/webm だけじゃダメでコーデック情報まで必要です
mp4 や webm はコンテナで 再生できるかどうかは中のコーデック次第だから仕方ないとは思います
ですが 調べるのが面倒なんですよね
ffmpeg についてる ffprobe を使って 拾ってきた動画の情報を表示したらこうなっていました

Input #0, matroska,webm, from 'video.webm':
Metadata:
COMPATIBLE_BRANDS: mp42mp41isomavc1
MAJOR_BRAND : mp42
MINOR_VERSION : 0
ENCODER : Lavf57.83.100
Duration: 00:02:10.39, start: 0.000000, bitrate: 407 kb/s
Stream #0:0: Video: vp8, yuv420p(progressive), 564x240, SAR 1:1 DAR 47:20, 23.98 fps, 23.98 tbr, 1k tbn, 1k tbc (default)
Metadata:
HANDLER_NAME : L-SMASH Video Handler
ENCODER : Lavc57.107.100 libvpx
DURATION : 00:02:10.342000000
Stream #0:1: Audio: vorbis, 48000 Hz, stereo, fltp (default)
Metadata:
HANDLER_NAME : L-SMASH Audio Handler
ENCODER : Lavc57.107.100 libvorbis
DURATION : 00:02:10.392000000

映像部分は VP8 で音声部分は vorbis です
なので mime type は「video/webm; codecs="vorbis, vp8"」と指定しています

コーデックパラメータの書き方はここに詳細があります
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter

上のはシンプルに vp8 だけ書いてますがちゃんと書くとこういう長さになります
「video/webm;codecs="vp09.02.10.10.01.09.16.09.01,opus"」
映像と音声の並びはどっちが先でも大丈夫です

あとはこの SourceBuffer に対して appendBuffer メソッドを使って ArrayBuffer を追加します
最後には endOfStream メソッドの呼び出しが必要です
これがないとまだ続きがあるとして扱われて 最後まで再生しても停止しません

分割する

appendBuffer の使い方をみると 1 つだけじゃなくてどんどん ArrayBuffer を追加していけそうです
動画の ArrayBuffer を分割して append するようにしてみました

<!doctype html>

<meta charset="utf-8" />

<div><span id="placeholder">Now loading...</span></div>
<button id="btn">Append</button>

<script>
!async function () {
const video_buffer = await fetch("video.webm").then(e => e.arrayBuffer())
const chunk_size = Math.ceil(video_buffer.byteLength / 5)

const video = document.createElement("video")
video.controls = true
const media_source = new MediaSource()
video.src = URL.createObjectURL(media_source)

media_source.addEventListener("sourceopen", () => {
const source_buffer = media_source.addSourceBuffer(`video/webm; codecs="vorbis, vp8"`)

let count = 0
const append = () => {
const chunk = video_buffer.slice(chunk_size * count, chunk_size * (count + 1))
source_buffer.appendBuffer(chunk)
count++
}

source_buffer.addEventListener("updateend", (a) => {
if (chunk_size * count >= video_buffer.byteLength) {
media_source.endOfStream()
btn.disabled = true
}
})

btn.addEventListener("click", append)

append()
})
placeholder.replaceWith(video)
}()
</script>

ArrayBuffer のバイナリデータを単純に 5 分割します
最初に 1 つめを append して 後はボタンが押されたら append するようにしています

再生を開始するとあちこちのページで見かけるように シークバーにはロード済み部分だけが明るくなってました
1 つめのチャンクで再生できる終わりまで来ると待機状態になります
ボタンを押して次のチャンクを追加すると続きの再生ができました

最後まで追加し終われば最後まで再生できます

サーバから配信

今回は最初に動画ファイル全体を fetch してからブラウザでバイナリデータを分割しました
最後まで再生するかわからないのに全部を fetch するのはムダが大きいです
なので普通はこの分割をサーバ側で行って 必要になったら追加で取得するはずです

Node.js なら fs.createReadStream のオプションに start と end を設定すればファイルの部分指定の stream が作れます
それを response に pipe するだけなので簡単です

const stream = fs.createReadStream(filepath, { start: i * chunk_size, end: (i + 1) * chunk_size })
stream.pipe(response)

どの部分が欲しいかの指定は GET リクエストのクエリで何番目かやどこからどこまでを指定してもいいのですが一応 Range というそれらしい HTTP ヘッダがあります
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range

こういうフォーマットで ファイルのどの範囲がほしいかを指定します

Range: bytes=1200-1600

それに対してのレスポンスには Content-Range です
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Range

Range とほぼ一緒ですが最後に全体のサイズが付きます

Content-Range: bytes 1200-1600/65536

Range を使ったときの部分的なレスポンスの場合はステータスコードも特殊で 206 が用意されています
https://developer.mozilla.org/ja/docs/Web/HTTP/Status/206

Range が不正だった場合のエラーは 416 です
https://developer.mozilla.org/ja/docs/Web/HTTP/Status/416

HTTP ヘッダでこういう仕組みがあるのなら 自分で作らなくてもライブラリやフレームワークで簡単にできるかもしれませんね

暗号化

JavaScript でサーバからデータを取得して ArrayBuffer を append するということは受け取ったデータを色々操作する事が可能です
となると サーバから受け取るのは暗号化したデータにして JavaScript で復号して再生ということもできます
ダウンロードツールでパートを全部ダウンロードして結合しても暗号化されたままなので見れないってことができますね

単純なものならこういうのでも十分そうです

const base = 91 // サーバ側と共有する適当な値
const key = base ^ (chunk_index % 256)
const decrypt = b => b ^ key

const decrypted = await fetch(url)
.then(e => e.arrayBuffer())
.then(a => new Uint8Array(a).map(decrypt).buffer)

xor をかけます
2 度かければ元通りの性質なのでサーバ側と同じ値で xor します
全部同じじゃなくて chunk の番号で少し xor の値を変えるようにしています

まぁここを複雑にしても appendBuffer に渡す状態では復号済みなわけで JavaScript を実際に動かして appendBuffer をフックして ArrayBuffer を取得すれば普通にダウンロードできますし そこまで頑張る必要もない気がします

動画サービスで動画を再生中に devtools の Network タブで通信を監視してると xhr でデータ本体らしいものの他に別のデータをダウンロードしているのを見かけます
あれが復号用のパスワードデータだったりするのかもしれませんね

未対応

基本的な再生処理はできましたが まだあったほうが良い機能もあります
実装は面倒なのでパスして未実装です

seek

まずはシークバーです
video 要素の seeked イベントでシーク後の場所を再生するためのチャンクを fetch して append します
シーク後のチャンクがどれなのかが分かれば対して困ることもないのですが 今は単純にバイト単位で分割しただけです
再生する時間からバイナリデータの場所ははっきりとわかりません

固定長ビットレートなら全体に対する割合でわかりそうな気もします
しかし 最初か最後にメタデータがあったりもしますし ずれはあるはずです
分割が細かすぎなければ たぶんこれだろうってチャンクの前後のチャンクもロードすれば大丈夫な気はしますが本当に確実なのか心配になります

それに動画なら可変長ビットレートのほうが主流な気がします
可変長なので動きが大きい部分でデータサイズが大きくなって動かない部分でデータサイズが小さくなります
そうなると再生地点の割合からバイナリデータの場所を推測してもかなりずれてることもありそうです

今回は単純にバイナリデータを 5 等分したものを使いましたが 時間で分割して 「13:05 なら 5 番目」 のように時間から分割したファイルのどれかがわかるようにしたほうが良さそうですね
それだとサーバへのリクエストで byte 単位で場所を指定しなくなるので Range に指定するのは bytes ではなく chunks とか segments でしょうか

ちゃんとしたサービスの場合は 1 つの動画でも解像度や画質など色々用意してるわけですし リクエストが来てリアルタイムに元の 1 ファイルから部分取得ではなく 事前に分割して解像度ごとにファイルを用意してるのだと思います

メモリ解放

高画質で長時間の動画だとデータサイズも大きいです
全体だと 数 GB ~ 数十 GB になって PC のメモリサイズを超えることも十分ありえます

取得は細かく分けても全部をメモリに乗せていけば後半がメモリ不足でクラッシュです
再生終わった部分はメモリ使用量に応じて解放していく必要があります

SourceBuffer インスタンスの remove メソッドで start, end を指定して削除できるようです
start と end は秒を表す double 型の数値で指定です
video 要素の timeupdate イベントを元に remove していくことになりそうです