音声と動画をプリロードして高速再生

リソースを積極的にプリロードしてメディアの再生を高速化する方法。

フランソワ ボーフォート
François Beaufort

再生開始が早ければ、より多くのユーザーが動画を視聴したり、音楽を聴いたりできるようになります。それは既知の事実です。この記事では、ユースケースに応じてリソースを積極的にプリロードすることで、音声と動画の再生を高速化する手法について説明します。

提供: 著作権 Blender Foundation | www.blender.org

メディア ファイルをプリロードする 3 つの方法について、それぞれの長所と短所から説明します。

すごい... でも...
動画のプリロード属性 ウェブサーバーでホストされている固有のファイルを簡単に使用できます。 ブラウザではこの属性が完全に無視されることがあります。
HTML ドキュメントが完全に読み込まれて解析されると、リソースの取得が開始されます。
Media Source Extensions(MSE)は、アプリが MSE にメディアを提供するため、メディア要素の preload 属性を無視します。
リンクのプリロード ドキュメントの onload イベントをブロックせずに、ブラウザが動画リソースのリクエストを強制します。 HTTP Range リクエストには互換性がありません。
MSE およびファイル セグメントと互換性があります。 リソース全体を取得するときに、小さいメディア ファイル(5 MB 未満)にのみ使用します。
手動バッファリング フル コント���ール 複雑なエラー処理はウェブサイトが行います。

動画のプリロード属性

動画ソースがウェブサーバーでホストされている一意のファイルである場合は、動画の preload 属性を使用して、プリロードする情報やコンテンツの量をブラウザに伝えることができます。つまり、Media Source Extensions(MSE)preload と互換性がありません。

リソースの取得は、最初の HTML ドキュメントが完全に読み込まれて解析されたとき(DOMContentLoaded イベントが発生したときなど)にのみ開始されますが、リソースが実際に取得されたときには大きく異なる load イベントが発生します。

preload 属性を metadata に設定すると、そのユーザーは動画を必要としませんが、そのメタデータ(ディメンション、トラックリスト、再生時間など)の取得が望ましいことを示します。なお、Chrome 64 以降、preload のデフォルト値は metadata です。(以前は auto でした)。

<video id="video" preload="metadata" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

preload 属性を auto に設定すると、ブラウザは追加のバッファリングを停止することなく、再生を完了できるだけの十分なデータをキャッシュできることを示します。

<video id="video" preload="auto" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

ただし、いくつか注意点があります。これは単なるヒントであるため、ブラウザは preload 属性を完全に無視することがあります。執筆時点で Chrome に適用されるルールは次のとおりです。

  • データセーバーが有効になっている場合、Chrome は preload 値を none に強制します。
  • Android 4.3 では、Android のバグにより、Chrome は preload の値を none に強制します。
  • モバイル接続(2G、3G、4G)の場合、Chrome は preload 値を metadata に強制します。

ヒント

ウェブサイトに同じドメインの動画リソースが多数ある場合は、preload 値を metadata に設定するか、poster 属性を定義して preloadnone に設定することをおすすめします。これにより、リソースの読み込みをハングさせる可能性がある、同じドメインへの HTTP 接続の最大数(HTTP 1.1 仕様では 6)に達するのを回避できます。ただし、動画が主要なユーザー エクスペリエンスに含まれていない場合は、ページの読み込み速度も向上する可能性があります。

他の記事説明されているように、リンクのプリロードは宣言型取得です。これを使用すると、ページのダウンロード中に load イベントをブロックすることなく、ブラウザがリソースに対するリクエストを行うように強制できます。<link rel="preload"> によって読み込まれたリソースはブラウザにローカルに保存され、DOM、JavaScript、CSS で明示的に参照されるまで実質的に不活性です。

プリロードは、現在のナビゲーションに焦点を当て、タイプ(スクリプト、スタイル、フォント、動画、音声など)に基づいた優先度でリソースをフェッチするという点で、プリフェッチとは異なります。現在のセッションのブラウザ キャッシュをウォームアップするために使用します。

動画全体をプリロードする

以下に、ウェブサイト上の動画全体をプリロードする方法を紹介します。これにより、JavaScript が動画コンテンツの取得を要求したときに、リソースがすでにブラウザによってキャッシュされている可能性があるため、コンテンツがキャッシュから読み取られます。プリロード リクエストがまだ完了していない場合は、通常のネットワーク取得が行われます。

<link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

<video id="video" controls></video>

<script>
  // Later on, after some condition has been met, set video source to the
  // preloaded video URL.
  video.src = 'https://cdn.com/small-file.mp4';
  video.play().then(() => {
    // If preloaded video URL was already cached, playback started immediately.
  });
</script>

この例では、プリロード済みリソースは動画要素によって消費されるため、as プリロード リンク値は video です。音声要素の場合は as="audio" です。

最初のセグメントをプリロードする

次の例は、<link rel="preload"> を使用して動画の最初のセグメントをプリロードし、Media Source Extensions で使用する方法を示しています。MSE JavaScript API に慣れていない場合は、MSE の基本をご覧ください。

わかりやすくするために、動画全体が file_1.webmfile_2.webmfile_3.webm などの小さなファイルに分割されていると仮定します。

<link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // If video is preloaded already, fetch will return immediately a response
    // from the browser cache (memory cache). Otherwise, it will perform a
    // regular network fetch.
    fetch('https://cdn.com/file_1.webm')
    .then(response => response.arrayBuffer())
    .then(data => {
      // Append the data into the new sourceBuffer.
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch file_2.webm when user starts playing video.
    })
    .catch(error => {
      // TODO: Show "Video is not available" message to user.
    });
  }
</script>

サポート

以下のスニペットを使用して、<link rel=preload> に対するさまざまな as タイプのサポートを検出できます。

function preloadFullVideoSupported() {
  const link = document.createElement('link');
  link.as = 'video';
  return (link.as === 'video');
}

function preloadFirstSegmentSupported() {
  const link = document.createElement('link');
  link.as = 'fetch';
  return (link.as === 'fetch');
}

手動バッファリング

Cache API と Service Worker の説明に入る前に、MSE を使用して動画を手動でバッファする方法を見てみましょう。以下の例では、ウェブサーバーが HTTP Range リクエスト���サポートしていることを前提としていますが、ファイル セグメントの場合も同様です。Google の Shaka プレーヤーJW PlayerVideo.js などのミドルウェア ライブラリは、この処理を行うようにビルドされています。

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // Fetch beginning of the video by setting the Range HTTP request header.
    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      sourceBuffer.appendBuffer(data);
      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });
    });
  }

  function updateEnd() {
    // Video is now ready to play!
    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);

    // Fetch the next segment of video when user starts playing the video.
    video.addEventListener('playing', fetchNextSegment, { once: true });
  }

  function fetchNextSegment() {
    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      const sourceBuffer = mediaSource.sourceBuffers[0];
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch further segment and append it.
    });
  }
</script>

考慮事項

これでメディア バッファリング エクスペリエンス全体を制御できるようになったので、プリロードを検討する際には、デバイスのバッテリー残量、「データセーバー モード」ユーザー設定、ネットワーク情報を考慮することをおすすめします。

バッテリーの認知度

動画のプリロードを検討する前に、ユーザーのデバイスのバッテリー残量を考慮してください。これにより、電力レベルが低い場合でもバッテリーを長持ちさせることができます。

デバイスのバッテリーが切れたら、プリロードを無効にするか、少なくとも解像度の低い動画をプリロードします。

if ('getBattery' in navigator) {
  navigator.getBattery()
  .then(battery => {
    // If battery is charging or battery level is high enough
    if (battery.charging || battery.level > 0.15) {
      // TODO: Preload the first segment of a video.
    }
  });
}

「データセーバー」を検出する

ブラウザで「データ節約」モードにオプトインしているユーザーに、高速で軽量なアプリケーションを配信するには、Save-Data クライアント ヒント リクエスト ヘッダーを使用します。このリクエスト ヘッダーを特定することで、費用とパフォーマンスの制約があるユーザー向けに、最適化されたユーザー エクスペリエンスをカスタマイズして提供できます。

詳細については、Save-Data による高速かつ軽量のアプリケーションの提供をご覧ください。

ネットワーク情報���基づくスマート読み込み

プリロードの前に navigator.connection.type を確認することをおすすめします。cellular に設定すると、プリロードを防ぎ、モバイル ネットワーク事業者が帯域幅を課金する可能性があることをユーザーに通知し、以前にキャッシュに保存されたコンテンツの自動再生のみを開始することができます。

if ('connection' in navigator) {
  if (navigator.connection.type == 'cellular') {
    // TODO: Prompt user before preloading video
  } else {
    // TODO: Preload the first segment of a video.
  }
}

ネットワーク情報のサンプルを確認して、ネットワークの変更に対応する方法も確認してください。

複数の最初のセグメントを事前キャッシュする

では、ユーザーが最終的にどのメディアを選択するかわからない状態で、投機的にメディ�� コンテンツをプリロードしたい場合はどうすればよいでしょうか。ウェブページに 10 本の動画が含まれている場合、各セグメントから 1 つのセグメント ファイルを取得するのに十分なメモリがあると考えられますが、10 個の隠し <video> 要素と 10 個の MediaSource オブジェクトを作成して、そのデータのフィードを開始すべきではありません。

以下の 2 つのパートからなる例では、強力で使いやすい Cache API を使用して、動画の最初のセグメントを複数事前キャッシュに保存します。IndexedDB でも同様のことを実現できます。Cache API は window オブジェクトからもアクセスできるため、Service Worker は使用していません。

フェッチとキャッシュ

const videoFileUrls = [
  'bat_video_file_1.webm',
  'cow_video_file_1.webm',
  'dog_video_file_1.webm',
  'fox_video_file_1.webm',
];

// Let's create a video pre-cache and store all first segments of videos inside.
window.caches.open('video-pre-cache')
.then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

function fetchAndCache(videoFileUrl, cache) {
  // Check first if video is in the cache.
  return cache.match(videoFileUrl)
  .then(cacheResponse => {
    // Let's return cached response if video is already in the cache.
    if (cacheResponse) {
      return cacheResponse;
    }
    // Otherwise, fetch the video from the network.
    return fetch(videoFileUrl)
    .then(networkResponse => {
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, networkResponse.clone());
      return networkResponse;
    });
  });
}

HTTP Range リクエストを使用する場合、Cache API は Range レスポンスをまだサポートしていないため、Response オブジェクトを手動で再作成する必要があります。networkResponse.arrayBuffer() を呼び出すと、レスポンスのコンテンツ全体が一度にレンダラのメモリに取得されるため、小さな範囲を使用することをおすすめします。

参考までに、上記の例の一��を変更して、HTTP Range リクエストを動画の事前キャッシュに保存しました。

    ...
    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })
    .then(networkResponse => networkResponse.arrayBuffer())
    .then(data => {
      const response = new Response(data);
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, response.clone());
      return response;
    });

動画を再生

ユーザーが再生ボタンをクリックすると、Cache API で利用可能な動画の最初のセグメントが取得され、利用可能な場合はすぐに再生が開始されます。それ以外の場合は、単にネットワークから取得します。ブラウザやユーザーがキャッシュを削除できます。

前述のように、MSE を使用して、動画のその最初のセグメントを動画要素にフィードします。

function onPlayButtonClick(videoFileUrl) {
  video.load(); // Used to be able to play video later.

  window.caches.open('video-pre-cache')
  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.
  .then(response => response.arrayBuffer())
  .then(data => {
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

    function sourceOpen() {
      URL.revokeObjectURL(video.src);

      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');
      sourceBuffer.appendBuffer(data);

      video.play().then(() => {
        // TODO: Fetch the rest of the video when user starts playing video.
      });
    }
  });
}

Service Worker で Range レスポンスを作成する

動画ファイル全体を取得して Cache API に保存したらどうなるでしょうか。Cache API はまだ Range レスポンスに対応していないため、ブラウザが HTTP Range リクエストを送信するときに、動画全体をレンダラのメモリに送る必要はありません。

そこで、これらのリクエストをインターセプトし、Service Worker からカスタマイズされた Range レスポンスを返す方法を紹介します。

addEventListener('fetch', event => {
  event.respondWith(loadFromCacheOrFetch(event.request));
});

function loadFromCacheOrFetch(request) {
  // Search through all available caches for this request.
  return caches.match(request)
  .then(response => {

    // Fetch from network if it's not already in the cache.
    if (!response) {
      return fetch(request);
      // Note that we may want to add the response to the cache and return
      // network response in parallel as well.
    }

    // Browser sends a HTTP Range request. Let's provide one reconstructed
    // manually from the cache.
    if (request.headers.has('range')) {
      return response.blob()
      .then(data => {

        // Get start position from Range request header.
        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);
        const options = {
          status: 206,
          statusText: 'Partial Content',
          headers: response.headers
        }
        const slicedResponse = new Response(data.slice(pos), options);
        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +
            (data.size - 1) + '/' + data.size);
        slicedResponse.setHeaders('X-From-Cache': 'true');

        return slicedResponse;
      });
    }

    return response;
  }
}

ここで重要なのは、response.blob() を使用してこのスライス化されたレスポンスを再作成したことです。これは単にファイルに対するハンドルを提供するだけなのに対し、response.arrayBuffer() はファイル全体をレンダラのメモリに取り込むためです。

カスタム X-From-Cache HTTP ヘッダーを使用して、このリクエストがキャッシュとネットワークのどちらからのものかを確認できます。ShakaPlayer などのプレーヤーでこれを使用すると、ネットワーク速度の指標として応答時間を無視できます。

Range リクエストを処理する方法に関する完全なソリューションについては、公式のサンプル メディアアプリ、特に ranged-response.js ファイルをご覧ください。