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

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

François Beaufort
François Beaufort

更快的开始播放速度意味着有更多用户观看您的视频或收听音频。这是众所周知的事实。在本文中,我将探索一些技巧,即根据您的用例主动预加载资源,加快音频和视频播放速度。

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

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

很棒... 但是…
视频预加载属性 易于使用,用于托管在网络服务器上的唯一文件。 浏览器可能会完全忽略该属性。
在 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 中应用了以下一些规则:

  • 启用流量节省程序后,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 和 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 实现类似的功能。我们尚未使用服务工作线程,因为 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 请求的完整解决方案。