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

[Media Source Extensionsを使ってみた (WebM編)](http://qiita.com/tomoyukilabs/items/57ba8a982ab372611669)では、Chrome(Blink)とFirefoxで「XHRやFetch APIで取得したWebMムービーを<video>タグで再生する手段」として[Media Source Extensions](http://w3c.github.io/media-source/) (MSE)の使い方を紹介しました。今回は、同様のやり方でMP4ムービーをXHRやFetch APIで取得して、MSEを利用して<video>タグで再生するやり方を紹介します。

MSEによるMP4 (ISO Base Media File Format; 以下、BMFF)の再生をサポートしているブラウザとしては、Chrome 23以降、Safari 8以降 (OS Xのみ)、IE11 (Windows 8以降)、Edgeがあります。

# その前に

WebMでは、MatroskaフォーマットによるElement構造を採用しており、メディアストリームはCluster単位で区切られて格納されるため、、そのまま初期化セグメントとメディアセグメントに分割できるようになっています。

一方、MP4(BMFF)の場合は、Boxという単位で各種の情報が分かれて格納されていますが、再生メディアのストリームの格納方法としては、先頭から最後まで分割せずに1ストリームを丸ごとBox(moov,mdat)に格納する方法と、フラグメント(moof,mdat)として短く区切りながら格納する方法の2種類が定義されています。一般的なビデオカメラ(カムコーダー)やスマートフォンのカメラ等で撮影して作成されるMP4ファイルの場合は、通常ではフラグメント構造を取らないことが多いため、MSEでMP4を扱うには、フラグメント化するように再エンコードする必要があります。

MSEで利用できるフラグメント化されたMP4ファイルを生成するには、[FFmpegでHTML5 readyな動画ファイルを作成](http://qiita.com/tomoyukilabs/items/c4eb7a829baac880797c#_reference-f75ecc543d461e4ede72)で紹介している、MPEG-DASH等のHTTPストリーミングに対応するフォーマットへの変換方法を用います。

まず、FFmpegでフラグメント化MP4ファイルに再エンコードします。

```bash
$ ffmpeg -i [入力ファイル名] \
    -vcodec libx264 -vb [動画のビットレート] -r [フレームレート] -x264opts no-scenecut -g [キーフレーム間隔] \
    -acodec libfaac -ac 2 -ab [音声のビットレート] \
    -frag_duration [フラグメントの時間長] -movflags frag_keyframe+empty_moov \
    [出力ファイル名.mp4]
```

ここで作成したフラグメント化MP4ファイルを、[MP4Box](http://gpac.wp.mines-telecom.fr/mp4box/)によってHTTPストリーミング対応のフォーマットに変換し、セグメント(フラグメント)単位でファイル分割すると、下準備は完了です。

```bash
$ MP4Box -dash [フラグメントの時間長] -frag-rap \
    -segment-name [メディアセグメントのファイル名の接頭辞] -profile ondemand \
    [MP4ファイル名]
```

# MP4でMSEを使ってみる

MP4でもMSEの使い方自体はWebMの場合と全く同様です。まず、MediaSourceオブジェクトを初期化し、SourceBufferオブジェクトを作成します。

```js
var ms = new MediaSource();
var sb, type;

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);
}

function initSourceBuffer() {
  sb = ms.addSourceBuffer(type);
  sb.addEventListener('updateend', appendMediaSegment, false);
  appendInitSegment();
}
```

ここで、`ms.addSourceBuffer()`の引数で指定するメディアタイプは、MP4Boxの出力に含まれるMPDファイルの内容から引用します。まず、MPDファイルの例を次に示します。

```xml:media.mpd
<?xml version="1.0"?>
<!-- MPD file Generated with GPAC version 0.5.2-DEV-rev971-g61bef99-master  at 2016-03-10T02:25:09.271Z-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H10M34.600S" maxSegmentDuration="PT0H0M2.000S" profiles="urn:mpeg:dash:profile:full:2011">
 <ProgramInformation moreInformationURL="http://gpac.sourceforge.net">
  <Title>test-512k_dash.mpd generated by GPAC</Title>
 </ProgramInformation>

 <Period duration="PT0H10M34.600S">
  <AdaptationSet segmentAlignment="true" maxWidth="640" maxHeight="360" maxFrameRate="30" par="1:1" lang="und">
   <ContentComponent id="1" contentType="video" />
   <ContentComponent id="2" contentType="audio" />
   <Representation id="1" mimeType="video/mp4" codecs="avc3.64001e,mp4a.40.2" width="640" height="360" frameRate="30" sar="1:1" audioSamplingRate="48000" startWithSAP="1" bandwidth="653758">
    <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
    <SegmentList timescale="1000" duration="2000">
     <Initialization sourceURL="movie_init.mp4"/>
     <SegmentURL media="movie_1.m4s"/>
       ...
     <SegmentURL media="movie_1000.m4s"/>
    </SegmentList>
   </Representation>
  </AdaptationSet>
 </Period>
</MPD>
```

ここで、メディアタイプは`<Representation>`要素の`mimeType`属性と`codecs`属性に記述されます。このMPDファイルをXMLHttpRequestで取得してXMLとしてパースし、メディアタイプを読み取ってSourceBufferを作成すると、例えば次のような手順となります。

```js
var mpd;

function getDescription(file) {
  var xhr = new XMLHttpRequset();
  xhr.open('GET', file);
  xhr.responseType = 'document';
  xhr.onload = function() {
    mpd = xhr.responseXML;
    var representation = mpd.getElementsByTagName('Representation')[0];
    var mimeType = representation.getAttribute('mimeType');
    var codecs = representation.getAttribute('codecs');
    type = mimeType + '; codecs="' + codecs + '"';
    initVideo();
  }
}

getDescription('media.mpd');
```

(現時点(2016-03-10)ではまだSafariやEdgeでFetch APIが(まだ開発中で)使えないため、XMLHttpRequestのサンプルを示しましたが、Fetch APIを使う場合は、まずテキストとしてMPDファイルの内容を取得してから、`DOMParser`を用いて`Document`オブジェクトにパースする必要があります。)

SourceBufferを生成してから初期化セグメントとメディアセグメントをSourceBufferに追加する手順も、WebMと同様です。今回の場合は予め各種セグメントが個別のファイルに分割されており、MPDファイルでその一覧も把握できるため、上記のサンプルコードに続いてMPDファイルの内容から初期化セグメントとメディアセグメントを取得してSourceBufferに追加する手順をサンプルとして示します。

なお、WebMの場合と同様に、まずはメディアセグメントが先頭の1個だけの場合をサンプルとして扱います。

MPDファイルにおいて、初期化セグメントは`<Initialization>`要素の`sourceURL`属性、メディアセグメントは`<segmentURL>`要素の`media`属性として格納されるため、これらの属性からファイル名を取得し、ダウンロードとSourceBufferへの追加を行います。(Webサーバ上ではMPDファイルと各種セグメントファイルは同一ディレクトリ内にあるものとします。)

```js
var mediaAppended = false;

function appendSegment(event) {
  sb.appendBuffer(event.target.response);
  if(mediaAppended)
    sb.removeEventListener('updateend', appendMediaSegment);
}

function appendMediaSegment() {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', mpd.getElementsByTagName('segmentURL')[0].getAttribute('media'));
  xhr.responseType = 'arraybuffer';
  xhr.onload = appendSegment;
  mediaAppended = true;
  xhr.send(null);
}

function appendInitSegment() {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', mpd.getElementsByTagName('Initialization')[0].getAttribute('sourceURL'));
  xhr.responseType = 'arraybuffer';
  xhr.onload = appendSegment;
  xhr.send(null);
}
```

初期化セグメントとメディアセグメントのどちらの場合についても、SourceBufferに追加した時にSourceBuffer内部でバッファとして格納完了した時に、SourceBufferオブジェクトで`updateend`イベントが発生します。一つセグメントを追加してから(初期化、メディアどちらでも)、次のセグメントの追加はこの`updateend`イベントが発生するのを待ってから行う必要があります。

## 連続する複数のメディアセグメントを扱う

WebM編では触れませんでしたが、MSEでは時間軸上で連続する複数のメディアセグメントをセグメント単位で追加や更新して再生する仕組みにも対応できるようになっています。

MSEにおいて、各メディアセグメントは、デフォルトでは必ずストリーム全体の先頭からの時刻をセグメントのヘッダ情報として格納することが必須となっています。当然ながら、WebMでもMP4でも各メディアセグメントは時刻情報(タイムスタンプ)を含むようになっています。このため、頭から順番に、ではなくても、例えばムービーの途中時刻のセグメントをいきなりSourceBufferに追加しても、その途中時刻に合わせてバッファに追加されるようにできています。ストリーミングしながらシークする際に便利です。

MPDファイルでメディアセグメントのリストを参照できる場合は、`<SegmentList>`要素の`timescale`属性で表されるタイムスケール(上記の例では`1000`が1秒に相当)と`duration`属性で表される1セグメントあたりの再生時間の長さから、各セグメントの先頭の再生時刻が計算できます。これを用いて各セグメントの時刻を計算することで、プレーヤの再生状況とセグメントのダウンロード速度に応じて、先読みして次に再生されるセグメントをダウンロードしてバッファに追加するといった挙動を実現することができます。

なお、デバイス(ブラウザ)のメモリにも限りがあるため、**SourceBufferにセグメントを多く追加すると、メモリ確保のため先に追加されたセグメントがバッファから除かれることがある**ため、特にシークして前の時刻に戻った時に、バッファに再生すべきデータがなくなっていた、ということが有り得るので注意が必要です。

実際にSourceBufferに格納されているバッファがどの再生時刻の部分を含んでいるかは、`buffered`プロパティから調べることができます。

* `sb.buffered.length`: バッファ済みの再生区間の数(すなわち、下記の`index`の値は`0`から`sb.buffered.length - 1`までの間)
* `sb.buffered.start(index)`: `index`番目のバッファ済み再生区間の先頭時刻
* `sb.buffered.end(index)`: `index`番目のバッファ済み再生区間の末尾時刻

### バッファ追加順にメディアセグメントが再生されるようにするには

Chrome 50では、セグメントのタイムスタンプではなく、[単純にセグメントの追加順にメディアが再生されるモードをサポートするようになっています](https://developers.google.com/web/updates/2016/03/mse-sourcebuffer)[[W3Cドラフト](http://w3c.github.io/media-source/#sourcebuffer)]。`SourceBuffer`オブジェクト(上記のサンプルでは変数`sb`)に、新たに`mode`プロパティが追加されており、その値の内容は次の通りです。

* `segments`(デフォルト値): 各セグメントのタイムスタンプ情報に応じてバッファにセグメントを格納し、再生
* `sequence`: 各セグメントのバッファへの追加順にバッファの先頭から順にセグメントを格納し、再生

## 補足

今回は、MP4Boxでセグメント単位で別ファイルに分割する場合を例として取り上げていますが、MP4(BMFF)フォーマットの内容が理解できる場合は、1つのファイルとしてまとめた上で、ArrayBufferとDataViewを使ってフォーマットを解析したり、HTTPリクエストのRangeヘッダを使ってセグメント毎に分割ダウンロードしたりしてSourceBufferに渡したり、といった処理もWebアプリで実装可能です。