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

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

François Beaufort
François Beaufort

再生開始が早いほど、動画や音声を視聴するユーザーが増えます。これは既知の事実です。この記事では、ユースケースに応じてリソースを積極的にプリロードすることで、音声と動画の再生を高速化できる手法について説明します。

クレジット: コピーライト Blender Foundation | www.blender.org

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

素晴らしい... ただし...
動画プリロード属性 ウェブサーバーでホストされている一意のファイルに簡単に使用できます。 ブラウザによっては、この属性が完全に無視される場合があります。
リソースの取得は、HTML ドキュメントが完全に読み込まれて解析されたときに開始されます。
Media Source Extensions(MSE)は、アプリが MSE にメディアを提供するため、メディア要素の preload 属性を無視します。
リンクのプリロード ドキュメントの onload イベントをブロックすることなく、ブラウザが動画リソースをリクエストするように強制します。 HTTP 範囲リクエストは互換性がありません。
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 PlayerJW 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 クライアント ヒント リクエスト ヘッダーを使用して、ブラウザで「データ節約」モードを有効にしているユーザーに、高速で軽量なアプリケーションを配信します。このリクエスト ヘッダーを特定することで、アプリケーションはコストとパフォーマンスの制約があるユーザーに対して、最適化されたユーザー エクスペリエンスをカスタマイズして提供できます。

詳細については、高速で軽量なアプリケーション(セーブデータを含む)の提供をご覧ください。

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

プリロードする前に 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.
      });
    }
  });
}

サービス ワーカーを使用して範囲レスポンスを作成する

動画ファイル全体を取得して Cache API に保存した場合はどうなりますか?ブラウザが HTTP Range リクエストを送信したときに、動画全体をレンダラ メモリに読み込むことは避けるべきです。Cache API は 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 ファイルを参照してください。