Media Source Extensionsを使ってみた (WebM編)

[モバイルブラウザのビデオ再生でアプリキャッシュが使えるかを再度検証](http://qiita.com/tomoyukilabs/items/2c929e614cc12ec40002)で紹介したように、Androidのブラウザでは、アプリケーションキャッシュを使っても、`<video>`タグでデータを先読みしてタップしたら直ちに再生する、といった挙動を実現することができませんでした。

そこで、少し手の込んだ方式になってしまいますが、Chrome等でサポートされている、Media Source Extensionsを使って、キャッシュされたWebMファイルを`XMLHttpRequest`でロードして`<video>`タグで再生してみます。

なお、だったら`XMLHttpRequest`でデータを取得して`Blob`を生成すればいいのではないか、という声も聞こえてきそうですが、残念ながら、これもまたモバイルブラウザでは上手く行かなかったりします。

# Media Source Extensionsの予備知識

そもそも、Media Source Extensions (MSE)は、HTTPダウンロードを利用してストリーミング再生するために作られた、HTML5用のJavaScript APIで、W3Cによって標準化されています。IE11とChrome(31以降はベンダプレフィクス不要)に対応しており、その他にも、Firefoxでは`about:config`で`media.mediasource.enabled`を`true`にすると利用できるようになり、また、OS X Yosemite版Safari 8でもサポートされる予定となっています。

ここでは、WebMフォーマットでMSEを使ってみることとします。よって、ブラウザはChrome(とBlinkベースのブラウザ)とFirefoxに限られます。

MSEを利用するには、メディアデータの構造がある一定に規則に従っていることが必要となっています。具体的には、再生されるメディアのデータが、短い時間で区切ったデータ構造にセグメント化されている必要があり、その詳細な仕様もW3Cで定められています。

## WebMとセグメント構造

W3Cでは[WebMのフォーマット要件](http://www.w3.org/2013/12/byte-stream-format-registry/webm-byte-stream-format.html)も定めていますが、実はFFmpegで(libvpxとlibvorbisを使って)作成したWebMファイルは、通常はこの要件を満たした状態になっていたりします。

さて、MSEでは、セグメントの種類を、初期化に必要なヘッダ情報である**初期化セグメント**と、短い時間で区切られたメディアデータ本体が含まれる**メディアセグメント**の2種類に分けて扱っています。

大まかな手順としては、最初に初期化セグメント、その後、メディアセグメントを順にバッファに渡すと、渡されたメディアセグメントから順に再生可能になる、といった具合になります。

# MSEを使ってみる

前置きが長くなりましたが、それでは順を追ってMSEを使ってみます。

## WebMファイルを取得

まず、メディアデータの取得は、普通に`XMLHttpRequest`を使います。

```js
var webm;
var xhr = new XMLHttpRequest();
xhr.open('GET', 'video_file_name.webm');
xhr.responseType = 'arraybuffer';
xhr.onload = function(evt) {
  webm = evt.target.response;
  initVideo();
};
xhr.send(null);
```

## MediaSourceを初期化

```js
var ms = new MediaSource();

function initVideo() {
  var video = document.getElementsByTagName('video')[0];
  ms.addEventListener('sourceopen', initSourceBuffer, false);
  if('srcObject' in video)
    video.srcObject = ms;
  else
    video.src = URL.createObjectURL(ms);
}
```

`MediaSource`オブジェクトを生成し、`video`タグにつながった時点で、`sourceopen`イベントが発生します。このタイミングより後に、次に示す手順で`SourceBuffer`オブジェクトを生成・追加し、その`SourceBuffer`オブジェクトにダウンロードしたセグメントを渡すことで、メディアの再生が可能になる、というのがMSEの原理になります。

## SourceBufferを作成

```js
var sb;

function initSourceBuffer() {
  // 動画だけで音声を含まない場合はcodecs="vp8"でよい
  sb = ms.addSourceBuffer('video/webm; codecs="vp8,vorbis"');
  sb.addEventListener('updateend', appendMediaSegment, false);
  appendInitSegment();
}
```

`SourceBuffer`では、`appendBuffer`メソッドでセグメントデータを渡すと、渡されたデータが正常に内部のバッファに蓄積された時点で`updateend`イベントが発生します。そのタイミングで、次のセグメントを渡すことができるようになります。(すなわち、`updateend`イベント発生前にセグメントデータを渡さないように気をつける必要があります。)

## WebMファイルをセグメントに分割

問題はここからです。WebMファイルのバイナリデータを初期化セグメントとメディアセグメントに分割する必要があります。

ざっとフォーマットの内容を説明すると、WebM(Matroska)はElementの階層構造となっており、FFmpegで作成したWebMファイルの場合、通常は、次のようなElement構造を取っています。

* EBML
* Segment
    * SeekHead
    * Void
    * Info
    * Tracks
    * Cluster
    * Cluster
    * ...
    * Cluster
    * Cues

ここで、**先頭のEBMLから、Segmentのうち先頭のSeekHeadからTracksまで(すなわち先頭のClusterの直前まで)を抜き出したものが初期化セグメント**、Segmentの中から**Clusterを一つずつ抜き出したものがメディアセグメント**、となります。

では、[Matroskaフォーマットの仕様書](http://www.matroska.org/technical/specs/index.html)を参考にして、具体的にセグメントの分割処理を行い、初期化セグメントとメディアセグメントを順に`SourceBuffer.appendBuffer()`でバッファに追加していく処理を実装してみます。

`sourceBuffer`には、最初に初期化セグメントを渡すと、処理が完了した時に`updateend`イベントが発生します。以後、メディアセグメントを渡す→`updateend`イベント発生、といった流れを最後のメディアセグメントまで繰り返せばよい、ということになります。

```js
var ptr = 0;

// Element名のうち必要となりそうなものだけを定義
// (今回はVoid以外は長さが同じとなる範囲でしかチェックしない)
var tagEBML = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]);
var tagSegment = new Uint8Array([0x18, 0x53, 0x80, 0x67]);
var tagCluster = new Uint8Array([0x1f, 0x43, 0xb6, 0x75]);
var tagVoid = new Uint8Array([0xec]);

// 配列(ArrayBufferView)が合致しているかどうかを比較
function equal(a, b) {
  if(a.byteLength != b.byteLength)
    return false;
  for(var i = 0 ; i < a.byteLength ; i++) {
    if(a[i] != b[i])
      return false;
  }
  return true;
}

// WebMフォーマットのElementサイズを計算
function getElementSize(d, p) {
  var l = 0;
  var n = d[p];
  var j;
  var t = 0;
  for(var i = 0 ; i < 8 ; i++) {
    if((n >> (7-i)) > 0) {
      j = i;
      break;
    }
  }
  for(var i = 0 ; i <= j ; i++) {
    var b = d[p + t];
    if(i == 0)
      b -= (1 << 7-j);
    l = l * 256 + b;
    t++;
  }
  return { length: l, offset: t };
}

// WebMファイルの先頭から初期化セグメントを取り出してSourceBufferに渡す
function appendInitSegment(evt) {
  var r;
  if(!equal(tagEBML, webm.subarray(ptr, ptr + tagEBML.byteLength))) {
    alert('WebM data error');
     return;
  }
  ptr += tagEBML.byteLength;
  r = getElementSize(webm, ptr);
  ptr += r.offset + r.length;
  if(!equal(tagSegment, webm.subarray(ptr, ptr + tagSegment.byteLength))) {
    alert('WebM data error');
    return;
  }
  ptr += tagSegment.byteLength;
  r = getElementSize(webm, ptr);
  ptr += r.offset;

  // Cluster手前までを検索
  while(!equal(tagCluster, webm.subarray(ptr, ptr + tagCluster.byteLength))) {
    if(equal(tagVoid, webm.subarray(ptr, ptr + tagVoid.byteLength)))
      ptr += tagVoid.byteLength;
    else
      ptr += tagCluster.byteLength;
    r = getElementSize(webm, ptr);
    ptr += r.offset + r.length;
  }
  // 初期化セグメント = WebMファイルの先頭から最初のClusterの直前まで
  var initSeg = new Uint8Array(webm.subarray(0, ptr));
  sb.appendBuffer(initSeg.buffer);
}

// Clusterを取り出してメディアセグメントとしてSourceBufferに渡す
function appendMediaSegment() {
   var start = ptr;

   // Clusterを最後まで読み終われば終了
   if(!equal(tagCluster, webm.subarray(ptr, ptr + tagCluster.byteLength)))
     return;

   ptr += tagCluster.byteLength;
   var r = getElementSize(webm, ptr);
   ptr += r.offset + r.length;
   var mediaSeg = new Uint8Array(webm.subarray(start, ptr));
   sb.appendBuffer(mediaSeg.buffer);
}
```

# むすび

今回はWebMファイルの先頭から順に再生するといった単純な処理をMSEで試してみましたが、MSEでは、シークした時にその時刻のセグメントからロードして`SourceBuffer`に渡したり、再生途中で異なるビットレートの動画や異なる言語の音声ファイルに切り替える、といったことも可能になるなど、他にも様々な使い方がありますので、興味のある方は色々お試し下さい。

今回はChrome(Blink)とFirefoxに対応するWebMフォーマットを扱いましたが、[Chrome、Safari 8以降(OS Xのみ)、IE11(Windows 8以降)、Edgeに対応するMP4フォーマットをMSEで扱うこともできます](http://qiita.com/tomoyukilabs/items/54bd151aba7d3edf8946)ので、興味のある方はお試し下さい。