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

リソースをアクティブにプリロードしてメディア再生を高速化する方法。

François Beaufort
François Beaufort

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

クレジット: Copyright 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 で適用されているルールは次のとおりです。

  • データセーバーが有効になっている場合、preload の値は none に設定されます。
  • Android のバグのため、Android 4.3 では 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 クライアント ヒント リクエスト ヘッダーを使用して、ブラウザで「データ節約」モードを有効にしているユーザーに、高速で軽量なアプリケーションを配信します。このリクエスト ヘッダーを特定することで、アプリケーションはコストとパフォーマンスの制約があるユーザーに対して、最適化されたユーザー エクスペリエンスをカスタマイズして提供できます。

詳細については、Delivering Fast and Light Applications with 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 に保存した場合はどうなるでしょうか。ブラウザが 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 ファイルを参照してください。