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

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

弗朗索瓦·博福
François Beaufort

更快的播放速度意味着有更多人观看或收听您的音频。这是已知事实。在本文中,我将探索一些方法,您可以利用这些方法根据您的使用场景主动预加载资源,从而加快音频和视频播放速度。

版权:Blender Foundation | www.blender.org

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

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

视频预加载属性

如果视频来源是网络服务器上托管的唯一文件,您可能需要使用视频 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"> 预加载视频的第一片段,并将其与 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 客户端提示请求标头,为在浏览器中选择启用“流量节省”模式的用户提供快速且精简的应用。通过识别此请求标头,您的应用可以为受费用和性能限制的用户自定义并提供经过优化的用户体验。

如需了解详情,请参阅利用 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 对象并开始馈送这些数据。

下面的两个部分示例展示了如何使用功能强大且易于使用的 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 中提供的第一片段,以便立即开始播放(如果有)。否则,我们只需从网络中获取即可。请注意,浏览器和用户可能会决定清除缓存

如前所述,我们使用 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 等播放器可使用它来忽略响应时间,用它来指示网络速度。

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