预加载音频和视频,从而快速播放

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

François Beaufort
François Beaufort

播放开始速度越快,观看视频或收听音频的用户就越多。这是众所周知的事实。在本文中,我将介绍一些技术,您可以根据自己的用例,通过主动预加载资源来加快音频和视频的播放速度。

鸣谢:版权所有 Blender Foundation | www.blender.org

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

太棒了... 但是…
视频预加载属性 适用于托管在 Web 服务器上的唯一文件,使用简单。 浏览器可能会完全忽略该属性。
在 HTML 文档完全加载并解析后,系统会开始提取资源。
媒体源扩展 (MSE) 会忽略媒体元素上的 preload 属性,因为应用负责向 MSE 提供媒体。
链接预加载 强制浏览器在不阻止文档的 onload 事件的情况下请求视频资源。 HTTP Range 请求不兼容。
与 MSE 和文件片段兼容。 提取完整资源时,应仅用于小型媒体文件(小于 5 MB)。
手动缓冲 完全控制 复杂的错误处理由网站负责。

视频预加载属性

如果视频源是托管在 Web 服务器上的唯一文件,您可能需要使用视频 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 bug,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 和服务工作器之前,我们先来看看如何使用 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 客户端提示请求标头,向在浏览器中选择启用“流量节省”模式的用户提供快速轻量级应用。通过识别此请求标头,您的应用可以自定义并为受成本和性能限制的用户提供优化的用户体验。

如需了解详情,请参阅利用 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 实现类似的功能。我们尚未使用服务工作线程,因为 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 中可用的第一个视频片段,以便立即开始播放(如果可用)。否则,我们只会从网络中提取它。请注意,浏览器和用户可能会决定清除缓存

如前所述,我们使用 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.
      });
    }
  });
}

使用服务工件创建 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 等播放器可以使用此方法来忽略响应时间,以便将其用作网络速度的指标。

查看官方媒体应用示例,尤其是其 ranged-response.js 文件,了解如何处理 Range 请求的完整解决方案。