音频和视频预加载的快速播放

如何通过主动预加载资源来加速媒体播放。

François Beaufort
François Beaufort

更快的播放开始意味着更多人观看您的视频或收听您的音频。这是一个众所周知的事实。在本文中,我将探讨您可以使用的技术,通过根据您的用例主动预加载资源来加速音频和视频播放。

致谢:版权归 Blender 基金会所有 | www.blender.org

我将介绍三种预加载媒体文件的方法,首先介绍它们的优点和缺点。

优点... 缺点...
视频预加载属性 易于用于 Web 服务器上托管的唯一文件。 浏览器可能会完全忽略该属性。
资源获取在 HTML 文档完全加载和解析后开始。
媒体源扩展 (MSE) 忽略媒体元素上的 preload 属性,因为应用负责向 MSE 提供媒体。
链接预加载 强制浏览器请求视频资源,而不会阻止文档的 onload 事件。 HTTP Range 请求不兼容。
与 MSE 和文件分段兼容。 在获取完整资源时,应仅用于小型媒体文件(<5 MB)。
手动缓冲 完全控制 复杂的错误处理是网站的责任。

视频预加载属性

如果视频源是 Web 服务器上托管的唯一文件,您可能需要使用视频 preload 属性向浏览器提供有关 要预加载多少信息或内容的提示。这意味着 媒体源扩展 (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 中应用的一些规则

  • 当启用 Data Saver 时,Chrome 会强制将 preload 值设置为 none
  • 在 Android 4.3 中,由于 Android 错误,Chrome 会强制将 preload 值设置为 none
  • 在蜂窝网络连接(2G、3G 和 4G)上,Chrome 会强制将 preload 值设置为 metadata

提示

如果您的网站在同一域名上包含许多视频资源,我建议您将 preload 值设置为 metadata 或定义 poster 属性并将 preload 设置为 none。这样,您可以避免达到同一域名的最大 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"> 预加载视频的第一个分段,并将其与媒体源扩展一起使用。如果您不熟悉 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 手动缓冲视频。下面的示例假设您的 Web 服务器支持 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 客户端提示请求标头,为选择在其浏览器中启用“数据节省”模式的用户提供快速轻巧的应用。通过识别此请求标头,您的应用程序可以为受成本和性能限制的用户自定义和提供优化的用户体验。

请参阅 使用 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 个视频的网页上,我们可能有足够的内存从每个视频中获取一个分段文件,但我们绝对不应该创建 10 个隐藏的 <video> 元素和 10 个 MediaSource 对象并开始馈送数据。

下面的两部分示例向您展示了如何使用功能强大且易于使用的 Cache API 预缓存视频的多个第一个分段。请注意,使用 IndexedDB 也可以实现类似的功能。我们尚未使用 Service Worker,因为 Cache API 也可以从 window 对象访问。

获取并缓存

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 请求,则必须手动重新创建 Response 对象,因为 Cache API 尚不支持 Range 响应。请注意,调用 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 中可用的视频的第一个分段,以便在可用时立即开始播放。否则,我们将简单地从网络获取它。请记住,浏览器和用户可能会决定清除 Cache

如前所述,我们使用 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)可以使用它来忽略响应时间作为网络速度的指标。

请查看官方 Sample Media App,特别是其 ranged-response.js 文件,以获得有关如何处理 Range 请求的完整解决方案。