预加载视频实现快速播放

avatar
UX @京东
原文链接: mp.weixin.qq.com

作者:François Beaufort | 译:Vicky·Ye

原文地址:

https://developers.google.com/web/fundamentals/media/fast-playback-with-video-preload

在以往的项目中,只要有视频的存在,那么就会是个让人费神的项目。且不说对它的适配兼容问题,只说它的加载问题就能说上半天了。本文作者从视频预加载的各种方法入手,讨论了如何让视频播放速度更快的解决办法。

众所周知,如果你的视频可以更快的播放意味着会有更多的人观看。在本文中,让我们一起通过一些预加载技术来加速视频播放。

注意: 除非另有说明,否则本文也适用于 audio 元素。

视频地址:

https://developers.google.com/web/fundamentals/media/fast-playback-with-video-preload

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

TL; DR

这很棒... 但...
视频 preload 属性 易用于 Web 服务器上托管的唯一文件。 浏览器可能完全忽略该属性。
HTML 文档完全加载和解析后,资源才开始获取。
当应用程序使用 MSE 扩展媒体时,MSE会忽略媒体元素上的 preload 属性。
Link preload 强制浏览器发出视频资源请求,但不会阻止文档的 onload 事件。 HTTP Range请求不兼容。
兼容 MSE 和文档片断。 获取完整资源时,只能是小型媒体文件(< 5MB)。
手动缓冲 完全控制 复杂的错误需要网页来处理。

视频预加载(preload)属性

如果网页中只有一个视频文件,您可能会使用 video 标签的 preload 属性来提示浏览器预加载的信息或内容量。但这意味着 Media Source Extensions(MSE)与 preload 将不兼容。

资源的获取将仅在HTML文档初始加载和解析完成后启动(例如, DOMContentLoaded 事件的触发),而 window.onload事件则完全不同,它在的资源都加载完成后才被触发。

preload 属性设置为 metadata 表示用户不想马上加载视频,但是需要预先获取其元数据(尺寸,轨道列表,时长等)。 请注意,从 Chrome 64 开始, preload 的默认值是 metadata(以前是 auto )。

  1. <video id="video" preload="metadata" src="file.mp4" controls></video>

  2. <script>

  3.  video.addEventListener('loadedmetadata', function() {

  4.    if (video.buffered.length === 0) return;

  5.    var bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);

  6.    console.log(bufferedSeconds + ' seconds of video are ready to play!');

  7.  });

  8. </script>

preload 属性设置为 auto 表示浏览器将缓存整个视频,无需暂停缓冲,可以支持完整播放。

  1. <video id="video" preload="auto" src="file.mp4" controls></video>

  2. <script>

  3.  video.addEventListener('loadedmetadata', function() {

  4.    if (video.buffered.length === 0) return;

  5.    var bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);

  6.    console.log(bufferedSeconds + ' seconds of video are ready to play!');

  7.  });

  8. </script>

由于 preload属性只是一个提示,浏览器可能会完全忽略 preload属性。写到这,请注意以下Chrome中的一些应用规则:

• 启用 Data Saver后 ,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)。 请注意,如果视频不属于您的核心用户体验,这样做也会提高网页加载速度。

Link preload

正如其他文章所述 ,link preload 是一种声明性资源获取,允许您强制浏览器在不阻止 window.onload 事件和页面渲染的情况下发出资源请求。 通过 <linkrel="preload"> 预加载的资源在DOM、JavaScript或CSS没有明确引用之前,被存储在本地浏览器中。

预加载 preload 与预读取 prefetch 的不同之处在于它侧重于当前页面的资源预加载,并根据它们的优先级(脚本,样式,字体,视频,音频等)获取资源。它通常用于为当前会话预热浏览器缓存。

预加载完整视频

以下示例讲述了如何在您的网站上预加载完整视频,以便当您的 JavaScript 请求获取视频内容时,它会从缓存中读取,因为视频资源可能已被浏览器缓存。 如果预加载请求尚未完成,则将进行常规网络获取。

  1. <link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

  2. <video id="video" controls></video>

  3. <script>

  4.  // Later on, after some condition has been met, set video source to the

  5.  // preloaded video URL.

  6.  video.src = 'https://cdn.com/small-file.mp4';

  7.  video.play().then(_ => {

  8.    // If preloaded video URL was already cached, playback started immediately.

  9.  });

  10. </script>

注意: 我建议仅将其用于小型媒体文件(<5MB)。

由于link预加载的 as 属性值为 video ,所以预加载资源将由例子中的视频元素使用。如果它是一个音频元素,它将是 as="audio"

预加载第一个片段

下面的示例显示了如何用 <linkrel="preload"> 来预加载视频的第一段内容,并将其与 Media Source Extensions 一起使用。 如果您不熟悉 MSE Javascript API ,请参阅 MSE 基础知识。

为简单起见,我们假设整个视频已被拆分为若干较小的文件,如“file1.webm”,“file2.webm”,“file_3.webm”等。

  1. <link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

  2. <video id="video" controls></video>

  3. <script>

  4.  const mediaSource = new MediaSource();

  5.  video.src = URL.createObjectURL(mediaSource);

  6.  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  7.  function sourceOpen() {

  8.    URL.revokeObjectURL(video.src);

  9.    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

  10.    // If video is preloaded already, fetch will return immediately a response

  11.    // from the browser cache (memory cache). Otherwise, it will perform a

  12.    // regular network fetch.

  13.    fetch('https://cdn.com/file_1.webm')

  14.    .then(response => response.arrayBuffer())

  15.    .then(data => {

  16.      // Append the data into the new sourceBuffer.

  17.      sourceBuffer.appendBuffer(data);

  18.      // TODO: Fetch file_2.webm when user starts playing video.

  19.    })

  20.    .catch(error => {

  21.      // TODO: Show "Video is not available" message to user.

  22.    });

  23.  }

  24. </script>

警告: 对于跨域问题,请确保正确设置了CORS请求头。 由于我们无法使用 fetch(videoFileUrl, { mode: 'no-cors' }) 检索未知响应所创建的缓存数组,因此我们无法将其提供给视频或音频元素。

支持

由于 link preload 尚未在每个浏览器中得到支持。您可以使用下面的代码检测其可用性,以调整您的展现效果。

  1. function preloadFullVideoSupported() {

  2.    const link = document.createElement('link');

  3.    link.as = 'video';

  4.    return (link.as === 'video');

  5. }

  6. function preloadFirstSegmentSupported() {

  7.    const link = document.createElement('link');

  8.    link.as = 'fetch';

  9.    return (link.as === 'fetch');

  10. }

手动缓冲

在我们深入了解 Cache API 和 Service Worker 之前,让我们看看如何使用 MSE 手动缓冲视频。 下面的例子模拟了支持 HTTP Range 请求的 Web 服务器,但这种方法与缓存文件片段非常相似。 请注意,一些插件库如 Google 的 Shaka Player ,JW Player 和 Video.js 都可以为您处理此问题。

  1. <video id="video" controls></video>

  2. <script>

  3.  const mediaSource = new MediaSource();

  4.  video.src = URL.createObjectURL(mediaSource);

  5.  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  6.  function sourceOpen() {

  7.    URL.revokeObjectURL(video.src);

  8.    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

  9.    // Fetch beginning of the video by setting the Range HTTP request header.

  10.    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })

  11.    .then(response => response.arrayBuffer())

  12.    .then(data => {

  13.      sourceBuffer.appendBuffer(data);

  14.      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });

  15.    });

  16.  }

  17.  function updateEnd() {

  18.    // Video is now ready to play!

  19.    var bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);

  20.    console.log(bufferedSeconds + ' seconds of video are ready to play!');

  21.    // Fetch the next segment of video when user starts playing the video.

  22.    video.addEventListener('playing', fetchNextSegment, { once: true });

  23.  }

  24.  function fetchNextSegment() {

  25.    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })

  26.    .then(response => response.arrayBuffer())

  27.    .then(data => {

  28.      const sourceBuffer = mediaSource.sourceBuffers[0];

  29.      sourceBuffer.appendBuffer(data);

  30.      // TODO: Fetch further segment and append it.

  31.    });

  32.  }

  33. </script>

注意事项

由于您现在采用手动控制缓冲整个媒体,我建议您在预加载时考虑下使用设备的电池电量、用户的“ Data-Saver 模式”首选项和网络信息等因素。

电池意识

在考虑预加载视频之前,请考虑用户设备的电池电量。 这将在电量较低时保持电池寿命。

当设备电池电量快耗尽时,禁用预加载或预加载分辨率较低的视频。

  1. if ('getBattery' in navigator) {

  2.  navigator.getBattery()

  3.  .then(battery => {

  4.    // If battery is charging or battery level is high enough

  5.    if (battery.charging || battery.level > 0.15) {

  6.      // TODO: Preload the first segment of a video.

  7.    }

  8.  });

  9. }

检测“Data-Saver”

使用 Save-Data 客户端提示请求头为在浏览器中启动“流量节省”模式的用户提供快速轻便的应用程序。通过识别此请求头,您的应用程序可以通过自定义限制成本和限制性能的方法为用户提供更好的用户体验。

通过阅读 使用 Save-Data 提供快速和轻量级应用程序 全文,了解更多信息 。

基于网络信息的智能加载

您可以在预加载之前检查 navigator.connection.type 。当它设置为 cellular 时,您可以阻止预加载并提示用户他们的移动网络运营商可能正在收费,并且只自动回放以前缓存的内容。

  1. if ('connection' in navigator) {

  2.  if (navigator.connection.type == 'cellular') {

  3.    // TODO: Prompt user before preloading video

  4.  } else {

  5.    // TODO: Preload the first segment of a video.

  6.  }

  7. }

查看 网络信息示例 了解如何对网络更改做出反应。

预缓存多个第一片段

如果我们想在不知道用户最终将选择哪一个视频进行播放的情况下,预先加载一些视频,那该如何操作呢?假设用户在浏览一个具有10个视频的网页,我们有足够的内存来缓存每个视频文件,但我们肯定不会去创建10个隐藏的 video 标签和10个 MediaSource 对象以及它们的数据。

下面的两个部分示例向您展示了如何使用功能强大且易用的 Cache API 来预缓存多个视频的第一个片段。需要注意的是,使用 IndexedDB 也可以实现类似的功能。这里我们没有使用 Service Workers,是因为 Cache API 也可以从 Window 对象中访问。

Fetch和Cache

  1. const videoFileUrls = [

  2.  'bat_video_file_1.webm',

  3.  'cow_video_file_1.webm',

  4.  'dog_video_file_1.webm',

  5.  'fox_video_file_1.webm',

  6. ];

  7. // Let's create a video pre-cache and store all first segments of videos inside.

  8. window.caches.open('video-pre-cache')

  9. .then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

  10. function fetchAndCache(videoFileUrl, cache) {

  11.  // Check first if video is in the cache.

  12.  return cache.match(videoFileUrl)

  13.  .then(cacheResponse => {

  14.    // Let's return cached response if video is already in the cache.

  15.    if (cacheResponse) {

  16.      return cacheResponse;

  17.    }

  18.    // Otherwise, fetch the video from the network.

  19.    return fetch(videoFileUrl)

  20.    .then(networkResponse => {

  21.      // Add the response to the cache and return network response in parallel.

  22.      cache.put(videoFileUrl, networkResponse.clone());

  23.      return networkResponse;

  24.    });

  25.  });

  26. }

请注意,如果我要使用 HTTP Range 请求,则必须手动重新创建 Response 对象,因为 Cache API 尚不支持 Range 请求。 还要注意的是,在调用 networkResponse.arrayBuffer() 时会立即响应,并将获取到的全部内容存入渲染器,这也是为什么建议您使用 Range 的原因。

以下代码中我修改了上面例子的部分代码,将 HTTP Range 请求的视频保存到预缓存中,供大家作为参考。

  1.      ...

  2.    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })

  3.    .then(networkResponse => networkResponse.arrayBuffer())

  4.    .then(data => {

  5.      const response = new Response(data);

  6.      // Add the response to the cache and return network response in parallel.

  7.      cache.put(videoFileUrl, response.clone());

  8.      return response;

  9.    });

播放视频

在 Cache API 中我们缓存了视频的第一片段,当用户点击播放按钮时,它可以立即开始播放。否则,我们需要从网络中获取它。需要注意的是,浏览器和用户可能会清除缓存 。

如前所述,我们使用 MSE 将视频的第一片段传给 video 元素。

  1. function onPlayButtonClick(videoFileUrl) {

  2.  video.load(); // Used to be able to play video later.

  3.  window.caches.open('video-pre-cache')

  4.  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.

  5.  .then(response => response.arrayBuffer())

  6.  .then(data => {

  7.    const mediaSource = new MediaSource();

  8.    video.src = URL.createObjectURL(mediaSource);

  9.    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  10.    function sourceOpen() {

  11.      URL.revokeObjectURL(video.src);

  12.      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

  13.      sourceBuffer.appendBuffer(data);

  14.      video.play().then(_ => {

  15.        // TODO: Fetch the rest of the video when user starts playing video.

  16.      });

  17.    }

  18.  });

  19. }

警告: 对于跨域问题,请确保正确设置了 CORS 请求头。 由于我们无法使用 fetch(videoFileUrl, { mode: 'no-cors' }) 检索未知响应所创建的缓存数组,因此我们无法将其提供给视频或音频元素。

使用Service Worker创建Range请求

现在,如果您已获取整个视频文件并将其保存在 Cache API中。 当浏览器发送 HTTP Range 请求时,您肯定不希望将整个视频存入渲染器内存,因为 Cache API 尚不支持 Range 请求。

那么,让我演示下如何拦截这些请求并从 service worker 返回自定义的 Range 请求头。

  1. addEventListener('fetch', event => {

  2.  event.respondWith(loadFromCacheOrFetch(event.request));

  3. });

  4. function loadFromCacheOrFetch(request) {

  5.  // Search through all available caches for this request.

  6.  return caches.match(request)

  7.  .then(response => {

  8.    // Fetch from network if it's not already in the cache.

  9.    if (!response) {

  10.      return fetch(request);

  11.      // Note that we may want to add the response to the cache and return

  12.      // network response in parallel as well.

  13.    }

  14.    // Browser sends a HTTP Range request. Let's provide one reconstructed

  15.    // manually from the cache.

  16.    if (request.headers.has('range')) {

  17.      return response.blob()

  18.      .then(data => {

  19.        // Get start position from Range request header.

  20.        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);

  21.        const options = {

  22.          status: 206,

  23.          statusText: 'Partial Content',

  24.          headers: response.headers

  25.        }

  26.        const slicedResponse = new Response(data.slice(pos), options);

  27.        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +

  28.            (data.size - 1) + '/' + data.size);

  29.        slicedResponse.setHeaders('X-From-Cache': 'true');

  30.        return slicedResponse;

  31.      });

  32.    }

  33.    return response;

  34.  }

  35. }

重点注意下例子中我用 response.blob() 重新创建了这个切片响应,是因为这可以让我操作( 在Chrome中 )通过 response.arrayBuffer() 对象存入内存的文件。

我自定义的 X-From-Cache HTTP 响应头可用于判断此请求是来自缓存还是来自网络。也可以用于像 ShakaPlayer 等播放器用它来忽略响应时间作为网络速度的指标。

视频地址:

https://developers.google.com/web/fundamentals/media/fast-playback-with-video-preload

这里有一个官方媒体应用程序的视频例子 ,特别是它的ranged-response.js文件,讲解了如何处理Range请求的完整解决方案。

索引列表

1、作者弗朗索瓦· 博福特 :

https://developers.google.com/web/resources/contributors/beaufortfrancois

2、视频演示:http://www.blender.org/

3、预加载的信息或内容量:

https://developers.google.com/web/fundamentals/media/video#preload

4、Media Source Extensions(MSE):

https://developers.google.com/web/fundamentals/media/mse/basics

5、Chrome 64:

https://developers.google.com/web/updates/2017/12/chrome-63-64-media-updates#media-preload-defaults-metadata

6、流量节省程序Data Saver:

https://support.google.com/chrome/answer/2392284

7、Android bug(安卓4.3系统错误):

https://bugs.chromium.org/p/chromium/issues/detail?id=612909

8、Link preload:

https://developers.google.com/web/updates/2016/03/link-rel-preload

9、Cache API:

https://developer.mozilla.org/en-US/docs/Web/API/Cache

10、Delivering Fast and Light Applications with Save-Data:

https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/save-data/

11、Network Information API Sample:https://googlechrome.github.io/samples/network-information/

12、Sample Media App :

https://github.com/GoogleChromeLabs/sample-media-pwa

13、ranged-response.js :

https://github.com/GoogleChromeLabs/sample-media-pwa/blob/master/src/client/scripts/ranged-response.js