NHN Cloud NHN Cloud Meetup!

Web技術で実装するAdaptive HTTP Streaming

昨今、世界的に膨大な量の動画コンテンツが消費されています。Flashが主流だったウェブ動画の技術が、次第に標準技術であるHTML5ビデオに切り替わり、最近ではほとんどの動画サービスはHTML5基盤でサービスされています。動画の技術は徐々に高度化され、ネットワーク環境に応じて最適な動画品質をストリーミングし、バッファがないサービスを提供するため、さまざまな方法が模索されています。そのうちの1つが新たにプロトコルを作成する代わりに、既存のHTTPを使って実装するAdaptive HTTP Streamingです。

Adaptive HTTP Streamingとは?

Adaptive HTTP Streamingは、文字通り適応型ストリーミングです。ユーザーのネットワーク状態に適応(反応)してストリーミングをするのが、この技術の主な目的です。似たような技術に、RTSP / RTMP Streamingがありますが、HTTPを使った技術ではなく、サービスを維持するために追加の作業やコストが必要でした。一般的に私たちが使用していたストリーミングは、Progressive download(以下PD)で、動画ソースが選択されるとそのコンテンツを最後までダウンロードして再生する方式でした。HTML5ビデオでも基本的にPDの形で使用できます。

(画像出典:http://abt.net/multimedia.php

PDは1つの解像度の動画ソースを選択してダウンロードしていく方式であるため、ネットワークの状況によって、ユーザーはバッファリングができ、その後ネットワークの状況が良くならなければ、継続してバッファリングできないという欠点があります。Adaptive Streamingは、まさにこの問題を解決するために作られました。アイデアはこうです。動画コンテンツをさまざまな解像度でエンコードして保存しておき、データ単位も動画コンテンツでは保存せず、細かく分割保存しておきます。そして、ユーザーが動画を再生する際に、ネットワークの状況に応じて適切にコンテンツのソースを選択し、最適なストリーミングサービスを提供します。さまざまなソースでエンコードがされているので、状況に応じて選択ができ、大きな1つのファイルではなく、細かく区切られたデータを1つずつダウンロードする方式であるため、次のデータを他のクオリティに簡単に交換できます。例えば、現在のユーザーのネットワーク状況が良くない場合は動画の480Pソースを一切れずつストリーミングし、状況が良くなれば、次の部分からそれ以上の解像度を持つソースを選択してストリーミングします。

(画像出典:http://abt.net/multimedia.php

NetflixやYouTubeの動画を視聴するとき、初めは解像度が良くない状況でも次第に解像度が良くなることを経験したことがあるでしょう。最初はユーザーのネットワークの状態を判断できないため、低解像度のコンテンツ部分をダウンロードし、ネットワークの品質が識別されると、それに伴う解像度で提供する戦略なのでしょう。適切なストリーミングを行うことでサービスを提供するサーバーのトラフィックを管理できる利点があるのはもちろん、ユーザーの立場でもネットワークデータの使用を軽減できるメリットがあり、バッファリングのない動画視聴が可能になります。

Adaptive Streamingのフロー

サーバーパーツ

Adaptive streamingを適用するには、PDとは異なり、動画をストリーミングするために事前に準備するものがたくさんあります。動画ファイルを解像度別にエンコードしておき、各ファイルの情報をクライアントに提供しなければなりません。全体的なフローは以下のとおりです。

  1. 動画をアップロードするとき、ファイルを小さな断片(セグメント)に切り取る
  2. セグメントは必要に応じて区分した帯域幅に対応した解像度でエンコードする
  3. 対応する解像度別のメディアセグメントの情報などを含むファイル(Manifest)をクライアントに提供する

動画ファイルを小さなセグメントにカットするときは、コーデック別に必要なツールを用います。動画の断片化されたセグメント情報を提供する文書フォーマットには、Apple-HLSと、MPEG-DASHがあります。

クライアントパート

WebクライアントでAdaptive Streamingが可能な標準技術としては、Media Source Extensions(MSE)があり、これによりストリーミングデータをプレイヤーに配信します。

  1. クライアントは再生する動画の解像度別セグメント情報を含めたManifestファイルをサーバーに要請する
  2. Manifestファイルを解析して必要な情報を取得した後、動画の情報、利用できる解像度のクオリティ、当該セグメントの取得場所(eg CDN URL)を把握する
  3. クライアントはユーザーのネットワーク帯域幅を測定し、Manifestの内容に応じて最適な動画クオリティを選択し、必要なセグメントをダウンロードする(セグメントをダウンロードしながら再び帯域幅測定)
  4. ダウンロードしたセグメントのデータをMSEバッファに提供する
  5. MSEはデータをデコードして動画オブジェクトに提供して再生する(3に進む)

Apple-HLS, MPEG-DASH

Adaptive HTTP Streamingを提供するManifestフォーマットの代表的なものとして、Appleが独自開発したHLSとMpegで標準化されたDASHがあります。HLSとDASHは動画ストリーミングのコンテンツ情報を含むManifestの仕様で、一種のプロトコルだと考えればよいでしょう。長短があるがHLSよりはDASHの方が拡張性があります。

Apple-HLS

Mac製品を対象に開発されたフォーマットで、Mac用のSafariとモバイル用のSafariではネイティブ対応されており、HTML5ビデオソースとしてHLS Manifestファイルを使って動画をストリーミングすることができます。Appleベンダ中心のフォーマットであるため、さまざまな機種に対するサポートは不十分ですが、Microsoft Edgeでもネイティブで内蔵されており、Android機種のブラウザにも対応しています。HLSがネイティブ対応されているため、ソースでHLS Manifestを用いると特別な作業はなく適応された動画ストリーミングが動作します。しかしこのようなネイティブ実装は、サービス側において戦略的にストリーミングを制御できないという欠点があります。つい最近までメディアコンテナをMP2TSのみ使用するようにスペックで制限されていましたが、MP2TSコンテナはパケットのヘッダによって、セグメントのサイズが増加するにつれ、ヘッダーによるオーバーヘッドが大きくなる問題があり、コーデックのブラウザの互換性にも問題がありました。特にMP2TSに対応していないChromeで正常に再生するには、DEMUXを通じてmp4に変換する必要がありました。2016年からはHLSでもMP4コンテナを使用できるようになりました。HLSはManifestでmp3音源リストを作成するときに使用していたM3U8プレイリストを利用します。m3u8は拡張に制限がある形式でコンテンツの種類を記述するため、メインm3u8とサブm3u8構造に分けて、コンテンツの種類とセグメント情報を表現します。

  • Appleで開発
  • Safariや特定ブラウザでは、HTML5 Videoのメディアソースで即時にHLSストリーミングを使用できる
  • モバイルSafariではMSEに対応しておらず、HLS以外は使用できない
  • メディアコンテナのフォーマット:mp2ts、mp4(2016)
  • Manifestでm3u8を使用
#EXTM3U
    #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=200000, RESOLUTION=720x480
    http://ALPHA.mycompany.com/lo/prog_index.m3u8
    #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=200000, RESOLUTION=720x480
    http://BETA.mycompany.com/lo/prog_index.m3u8

    #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=500000, RESOLUTION=1920x1080
    http://ALPHA.mycompany.com/md/prog_index.m3u8
    #EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=500000, RESOLUTION=1920x1080
    http://BETA.mycompany.com/md/prog_index.m3u8
    ....中略....

Mpeg-DASH

MPEGとISOによって批准された標準フォーマットです。特定のベンダが独自開発したスペックではなく、文字通り標準です。ベンダ基盤ではないため、ネイティブ対応するブラウザはまだなく、特別な作業がなくHTML5ビデオにソースとして使用できるブラウザもありません。以後で説明するMSEを通じて、サービスが望むように最適化されたストリーミングを実装しなければなりません。主な特徴は、メディアフォーマットに制限がなく、またManifestであるMedia Presentation Description(MPD)はXML基盤で作成され、メインコンテンツの他、広告などを挿入できるほど表現力が豊かです。その豊かな表現力のためm3u8とは異なり、1つのManifestファイルにすべての情報を含めることができます。

  • MPEG、ISO批准標準
  • DashはMSEを使ってブラウザのネイティブ再生機能を利用できる
  • Dashはメディアコンテナのフォーマットに制限がない
  • 広告が簡単に挿入できる(Period構成)
  • ManifestがXMLで構成されており豊かな表現が可能。さまざまな情報をMPDで提供する
<?xml version="1.0"?>
   <MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:full:2011" minBufferTime="PT1.5S">
       <!-- Ad -->
       <Period duration="PT30S">
           <BaseURL>ad/</BaseURL>
           <!-- Everything in one Adaptation Set -->
           <AdaptationSet mimeType="video/mp2t">
               <!-- 720p Representation at 3.2 Mbps -->
               <Representation id="720p" bandwidth="3200000" width="1280" height="720">
                   <!-- Just use one segment, since the ad is only 30 seconds long -->
                   <BaseURL>720p.ts</BaseURL>
                   <SegmentBase>
                       <RepresentationIndex sourceURL="720p.sidx"/>
                   </SegmentBase>
               </Representation>
               <!-- 1080p Representation at 6.8 Mbps -->
               <Representation id="1080p" bandwidth="6800000" width="1920" height="1080">
                   <BaseURL>1080p.ts</BaseURL>
                   <SegmentBase>
                       <RepresentationIndex sourceURL="1080p.sidx"/>
                   </SegmentBase>
               </Representation>
   ....中略....

MSE(Media Source Extensions)

HLSの場合、Mac用のSafariやモバイル用のSafariでメディアソースとして適用できますが、DASHの場合は大半がMSEを利用してメディアソースを拡張します。DASHやHLSはストリーミングするメディアデータの情報を動画プレイヤーに配信する目的で作られたため、実質的に再生に関与する部分ではありません。MSEは、DASHあるいはHLS Manifestを通じて必要なメディア情報を取得した後、実質的にメディアの部分を、ウェブ上でHTML5ビデオを再生するときに使用されます。(もちろんSafariでは、HLS Manifestをそのままソースとして使用できます。)MSEはHTML5のビデオ動画を再生するとき、ソースを提供する目的で使用していたsourceタグの代用として、HTMLMediaElementを用いて開発者が新しいメディアソースを定義できるようにするインターフェースです。開発者が再生される動画のデータをHTTP経由で受信した後、SourceBufferオブジェクトを使ってHTMLMediaElementにメディアバッファを提供する方法で開発します。動画再生時にHTML5ビデオが必要なデータを開発者が関与して提供できるようにしたものです。MSEの実装コードを表示する前に、簡単に重要な概念をいくつかを見てみよう。

セグメント

セグメントはエンコードされた動画データの小さな部分です。この部分はDASHやHLSを通じて取得し、ユーザーが作品の一部を動画で見られるようにプレイヤーに配信するものです。セグメントにはInitialization Segment(初期化セグメント)とMedia Segment(メディアセグメント)の2種類があります。初期化セグメントは、実際の動画の情報を含むメディアセグメントのシーケンスをデコードするのに必要な情報を持ち、コーデックの初期化データ、トラックID、タイムスタンプオフセットなどの情報を含んでいます。メディアセグメントは、パケット化された、自分が再生すべきメディアタイムライン上のタイムスタンプ情報を含む実際の動画データです。メディアセグメントは、初期化セグメントの情報をもとに、自分の位置が分かるため、メディアセグメントを順次プレイヤーに提供しなくても再生されるべき位置で再生されます。逆に初期化セグメントがない場合は、メディアセグメントのデータをいくらプレイヤーに提供しても正常に再生することはできません。

MediaSourceオブジェクト、SourceBufferオブジェクト

MediaSourceはHTMLMediaElementのメディアデータソースを示します。動画プレイヤーで再生される1種類のメディアと理解すればよいでしょう。SourceBufferを利用してMediaSourceにメディアセグメントを伝達し、HTMLMediaElement(Video)は再生しながら、必要なデータをMediaSourceから取り出して使用します。
構造的にHTMLMediaELementがMediaSourceを使い、MediaSourceはSourceBufferを所有して使用します。
(画像出典:https://www.w3.org/TR/media-source/

HTML5 VideoとMSE連動させる

Google検索するとMSEに関する資料がでてきますが、リリースされて間もないスペックなので、MSEを正しく理解するには、W3C仕様書を見るのが最も確実な方法であるようです。幸いなことにスペック内容が少ないのですぐに読めます。MSEのインタフェースの使用方法だけを知りたい場合はアルゴリズムの部分は省略すればよいでしょう。仕様書の全体的な内容は一番下のサンプルコードで確認できます。このサンプルコードをコピーして1行1行コメントをつけて説明しようと思いますが、その前にいくつかの重要な部分だけを先に見てみよう。

動画プレイヤー、つまりVideo(HTMLMediaElement)オブジェクトにストリーミングソースを提供する技術であるMSEの核心はMediaSourceです。MediaSourceオブジェクトを作成し、Videoオブジェクトを接続するのが最初の作業となります。

var mediaSource = new MediaSource();
video.src = window.URL.createObjectURL(mediaSource);

VideoとMediaSourceはObject URLによって接続されますが、window.URL.createObjectURL関数を使ってMediaSourceオブジェクトのObject URLを作ります。
そしてVideoと接続すると、MediaSourceオブジェクトはストリーミングデータを受信する準備ができたことを知らせるイベントsourceopenを発生し、このイベントを皮切りに追加作業を行うことになります。
MediaSourceが準備できたらSourceBufferオブジェクトを作成します。その後、繰り返しセグメントを持ってきてSourceBufferを通じてストリーミングするデータをVideoに配信します。

var sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vorbis,vp8"');

addSourceBufferはコーデック情報を引数として受け取り、当該コーデックのデータをデコードできるSourceBufferオブジェクトを返却します。その後、サーバーからメディアセグメント情報を受け取り、ソースバッファに提供する作業は、SourceBufferオブジェクトのappendBufferメソッドを使用します。Ajaxでメディアセグメント情報を受け取る際のresponse typeはArrayBufferを使用します。

var xhr = new XMLHttpRequest;
xhr.open('get', url);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
  sourceBuffer.appendBuffer(xhr.response);
};
xhr.send();

appendBufferを使用した後は、MediaSource内部でデータをデコードする処理が実行されます。その際、新しいバッファのデータを提供してはいけません。以降のビデオオブジェクトのprogressイベントなどを利用して持続し、バッファを取得して、提供する作業を実行する必要があります。
簡単に主要インターフェイスを中心にフローを調べました。w3c仕様書の下段にあるサンプルコードをコメントと一緒に見てみよう。

<video id="v" autoplay> </video>

<script>
  var video = document.getElementById('v');

  // 新しいMediaSourceを作る。
  var mediaSource = new MediaSource();

  // MediaSourceがVideoにつながり、ストリーミングデータを受け取る準備ができたら、sourceopenイベントが発生する。
  mediaSource.addEventListener('sourceopen', onSourceOpen.bind(this, video));

  // ビデオオブジェクトにMediaSourceを連結する。
  video.src = window.URL.createObjectURL(mediaSource);

  // メディアソースがオープンされると実行されるハンドラーである。
  function onSourceOpen(videoTag, e) {
    var mediaSource = e.target;

    // 不要な状況でsourceopenイベントが発生するときを省く。sourceBufferが必要。
    if (mediaSource.sourceBuffers.length > 0)
        return;

    // メディアソースにaddSourceBufferメソッドを用いてsourceBufferを作る。印字はコーデック情報である。
    // サンプルコードでsourceBufferはwebmコーデックでエンコードされたデータが取得できるようになる。
    var sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vorbis,vp8"');

    // ビデオオブジェクトの必要に応じてバッファーを提供する必要があり、ハンドラーをかける。
    videoTag.addEventListener('seeking', onSeeking.bind(videoTag, mediaSource));
    videoTag.addEventListener('progress', onProgress.bind(videoTag, mediaSource));

    // アプリケーションコードで初期化セグメントを取得する。 もちろんこの過程は非同期作業だが...
    // 初期画面のメディアセグメントはajax要請時に応答タイプを"arrayBuffer"で受ける。
    var initSegment = GetInitializationSegment();

    if (initSegment == null) {
      // 初期化セグメントが持てなければ、再生できない。
      // mediaSource.endOfstreamメソッドでストリームを終了する。このメソッドは正常にストリームが終了した時も呼び出され
      // エラーによる終了日の際も原因を印字で渡して終了する。"network" or "decode"
      mediaSource.endOfStream("network");
      return;
    }

    // 初期化セグメントをsourceBufferに提供する。
    // firstAppendHandlerの初期化セグメントが正常にsourceBufferに入ってから1回実行され、イベントハンドラーから除去される。
    // 初期化セグメントが入ってから、メディアセグメントに切り替えるために使用される関数である。
    var firstAppendHandler = function(e) {
      var sourceBuffer = e.target;
      sourceBuffer.removeEventListener('updateend', firstAppendHandler);

      // 下記の関数で本格的にメディアバッファをSourceBufferに提供する段階に移る。
      appendNextMediaSegment(mediaSource);
    };

    // sourceBuferはメディアデータを受けると当該データをデコードする作業を行うが
    // update はジョブが成功裏に終了したとき、updateendは成功/失敗に関係なく終了したときに発生する。
    // ここでは初期化セグメントを提供し、メディアセグメントを提供するために使用される。
    sourceBuffer.addEventListener('updateend', firstAppendHandler);
    sourceBuffer.appendBuffer(initSegment);
  }


  // 初期化セグメントが提供されてからメディアセグメントを提供する関数
  // 初回実行以降はビデオオブジェクトのプログレスイベントによって実行される。
  function appendNextMediaSegment(mediaSource) {
    // MediaSource.readyStateは"open", "closed", "ended"の3つのステータスを持つ。
    // "open"は現在のメディアデータを処理中であり、"ended"は待機状態、
    // "closed"はこれ以上のメディアストリームは受け付けない。
    if (mediaSource.readyState == "closed")
      return;

    // アプリケーションコードでこれ以上提供するメディアセグメントがなければ、endOfStreamでストリーミングを終了する。
    if (!HaveMoreMediaSegments()) {
      mediaSource.endOfStream();
      return;
    }

    // 動画バッファを提供する過程はデータをデコードする過程を経るため、時間とCPU費用がかかる。
    // 常にsourceBufferがupdating状態であるかをチェックし、新しいバッファを提供しなければならない。
    // updatingがtrueの場合、以前のメディアデータの処理中である。
    if (mediaSource.sourceBuffers[0].updating)
        return;

    // アプリケーションコードである次のメディアセグメントを受け取る。
    var mediaSegment = GetNextMediaSegment();

    if (!mediaSegment) {
      // なければエラー
      mediaSource.endOfStream("network");
      return;
    }

    // メディアデータをsourceBufferに提供する。
    // MediaSource.readyStateが"ended"または"open"なら
    // sourceopenイベントにかかっているonSourceOpenハンドラーが再実行されるので対処が必要。
    mediaSource.sourceBuffers[0].appendBuffer(mediaSegment);
  }

  // seekingイベントハンドラーでシーキングされた当該位置のメディアデータを提供する作業を行う。
  function onSeeking(mediaSource, e) {
    var video = e.target;

    // sourceBufferで処理されているバッファがあれば取り消す。
    if (mediaSource.readyState == "open") {
      mediaSource.sourceBuffers[0].abort();
    }

    // アプリケーションコードからビデオオブジェクトで現在の動画再生位置を読み込み、当該メディアセグメントを用意する。
    SeekToMediaSegmentAt(video.currentTime);

    // MediaSourceに変更されたバッファーを提供する。
    appendNextMediaSegment(mediaSource);
  }

  // progressイベントハンドラーで再生されるセグメントのデータを準備し、SourceBufferに提供する。
  function onProgress(mediaSource, e) {
    appendNextMediaSegment(mediaSource);
  }
</script>

サンプルコードの全体的なフローを要約すると以下のとおりです。

  1. MediaSourceの新しいインスタンスを作り、ビデオオブジェクトとObject URLに接続する
  2. MediaSourceが準備されると、sourceOpenイベントが発生する
  3. MediaSourceで使用されるコーデックのデータをデコードできるsourceBufferを準備する
  4. SourceBufferに初期化セグメントを提供し、デコードが完了したらメディアセグメントを提供する
  5. その後、progressとseekingイベントによっては、ビデオオブジェクトのタイムラインの位置に対応するメディアセグメントを提供します。

サンプルコードは、純粋にMSEの使用例を記述したものですが、実際のサービスでは、DASHやHLS形式のManifestをダウンロード、解析し、メディア情報を取得する部分が必要です。そしてユーザーのBandwidthをチェックして、適切なクオリティを選択するコードも必要でしょう。帯域幅をチェックして最適な解像度を選定する部分は思ったより簡単にはいきません。クライアントからの帯域幅を測定すると、測定タイミングにより偏差が大きいため(特にモバイル)、帯域幅の値をどのように加工して使用するか十分に検討する必要があります。通常はEWMA Control Chartsを利用して母数を推定します。

おわりに

Webブラウザ上でAdaptive HTTP Streamingを実装する全般的な技術を紹介しました。現在、MSEはEncrypted Media Extensionsと連携してDRMまでカバーすることができるようになっています。かつてはFlashやMicrosoft Silverlightでなければ不可能であったことが、徐々に標準的なWeb技術だけで可能になりつつあり、このようなWeb技術が出てくる速度はますます加速しています。今後どのような技術が登場して何を可能にしてくれるのか、期待されます。

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop