分片加载视频的实现,边播放边加载

发布时间 2024-01-08 10:38:45作者: 柯基与佩奇

背景

最近公司的一个项目,首页中用到了一段炫酷的 mp4 视频作为背景,一开始视频有点大,打开时间有点慢,后来直观的思维,视频需要压缩一下,小一点。设计人员也配合的很好,压缩了很多。但是转念一想,大视频就没辙了吗,于是调研了一下大视频的加载方案,我觉得无非就是两种,一种是把视频物理切割一下,变成好几个小视频,另一种就是分片加载。第一种我觉得每次手动切太麻烦,我想用第二种,分片加载的方法。

range

那么想到分片,第一个想到的肯定是range字段。

range 简介

首先来了解一下 http 请求头中的range字段:

HTTP 的 Range 头字段用于指定客户端请求服务器发送指定范围的响应实体(例如,文件的一部分)。这在处理大文件或流媒体等情况下特别有用,因为客户端可以仅请求所需的部分数据,而不必下载整个文件。 Range 头字段的格式如下:

Range: bytes = start - end;

HTTP 的 Range 头字段用于指定客户端请求服务器发送指定范围的响应实体(例如,文件的一部分)。这在处理大文件或流媒体等情况下特别有用,因为客户端可以仅请求所需的部分数据,而不必下载整个文件。

Range 头字段的格式如下:

Range: bytes=start-end

其中,startend 表示字节范围的起始和结束位置。请注意,范围是从 0 开始的,而且是包含 start 字节但不包含 end 字节的。

*以上摘自 chatgpt 的回答

使用

那么在代码中如何应用,我们以 fetch 为例:

fetch(assetURL, {
  headers: {
    Range: `bytes=${start}-${end}`,
  },
});

就这么简单,具体的效果,我们在后文体现。

blob 实现

既然了解了range那其实就可以着手实现了。 但是再理一理思路,我们要拿到的是视频文件,并且是从后端接口返回的数据,不再是一个地址,不是直接从服务器去获取静态资源,那么我们从接口拿到的是一种什么格式的数据,然后我们如何渲染这个数据?

二进制数据

接口返回的视频数据应该是二进制流。

也就是说我们前端拿到的数据是一些二进制数据,而不再是我们熟悉的数组,字符串,对象什么的。

前端其实操作二进制数据的场景很少,大部分涉及到的场景都和各种文件有关系,比如说前端拿到后端返回的文件数据后,在前端做下载的操作,就需要利用那些二进制流生成文件。

那么明确了数据的格式后,我们应当如何操作这些二进制数据生成视频文件呢?

blob 登场!!!

blob 简介

blob 表示二进制大对象,是 JavaScript 对不可修改二进制数据的封装类型。包含字符串的数组、ArrayBuffer、ArrayBufferViews,甚至其他 Blob 都可以用来创建 blob。

摘自《JavaScript 高级程序设计》(第四版)

Blob(Binary Large Object)是一种数据类型,表示一个不可变的、原始数据的类文件对象。它通常用于存储二进制数据,如图像、音频、视频文件,以及其他类似的数据。

来自 chatgpt 的回答

用 blob 实现分片加载并同时播放

理论上来说,了解了 range 和 blob 这两个知识点之后,我们就清楚了如何获取分片的视频数据和如何处理获取的这些数据使纸成为视频,我们也就可以着手实现效果了。

那下面我们就开始实现吧!!!

后端接口

这里我们展示一下后端的 koa2 的接口,主要展示一下后端接口如何处理 range 字段。

router.get("/getFmp4", async (ctx) => {
  const stat = fs.statSync(path.join(__dirname, "fmp4.mp4"));
  const range = ctx.req.headers.range;
  const parts = range.replace(/bytes=/, "").split("-");
  const start = Number(parts[0]);
  const end = Number(parts[1]) || stat.size - 1;
  ctx.set("Content-Range", `bytes ${start}-${end}/${stat.size}`);
  ctx.type = "video/mp4";
  ctx.set("Accept-Ranges", "bytes");
  ctx.status = 206;
  const stream = fs.createReadStream(path.join(__dirname, "fmp4.mp4"), {
    start,
    end,
  });
  ctx.body = stream;
});

前端代码

主要还是看一下前端的实现。

const rangeVideo = () => {
  const totalSize = 5524488;
  const chunkSize = 500000;
  const numChunks = Math.ceil(totalSize / chunkSize);
  let chunk = new Blob();
  let index = 0;

  send();
  function send() {
    if (index >= numChunks) return;
    const start = index * chunkSize;
    const end = Math.min(start + chunkSize - 1, totalSize - 1);
    fetch("url", {
      headers: { Range: `bytes=${start}-${end}` },
    })
      .then((response) => {
        index++;
        return response.blob();
      })
      .then((blob) => {
        send();
        chunk = new Blob([chunk, blob], { type: "video/mp4" });
        const url = URL.createObjectURL(chunk);
        const currentTime = video.currentTime;
        video.src = url;
        video.currentTime = currentTime;
        video.play();
      });
  }
};

代码的主要思路是:

  • 先拿到这个文件的具体大小
  • 然后定下来每次分片的大小
  • 接着去做一个递归的请求去依次获取分片
  • 然后把每一次拿到的数据按顺序拼起来
  • 这样最终我们就能拿到所有的数据,并且在前端拼接成一个完整的视频数据

我们先来看看整体的效果

效果

先看一下最终的效果 jj.gif

再看一下整体的请求

image.png

看一下其中一个请求

image.png

可以看见整体和预期的差不多,又差不少,哈哈哈。

我们分析一下上面的代码和最终的效果

分析

请求顺序

上面代码用了递归来依次发送请求,而不是用循环来实现。 因为视频数据的拼接必须是严格安装顺序来的,顺序不对加载的时候会立刻出错。 请求的时候虽然前面的内容都是一样大小,但是网络的波动可能会导致接受时候顺序出错,所以这里采用了上一个请求结束才开始下一个请求的写法。

播放时间

这里是用作背景视频,所以要求自动播放,所以在代码里面,每获取一部分新的视频,就拼接起来,更新一下视频的地址,然后再重新设置一下播放的时间

URL.createObjectURL

看一下我们生成的视频的地址,他是一个对象 URL,也叫 Blob URL,是指引用存储再 File 或者 Blob 中数据的 URL。

image.png

URL.createObjectURL 函数返回的值是一个指向内存中地址的字符串。

在这里我们拿到 Blob 数据后,就是通过这个函数生成了对象 URL,然后作为视频的 src。

不足

这里虽然实现了分片加载和自动播放的功能,但是很明显,在每次赋值 src 的时候,视频会有刷新的动作,体验很不好,而且下面显示的时间会变,我们获取到多少视频,他的视频市场就会显示多少,这也不是很好。

所以就是说,有没有办法能解决?让他一下就知道有多久的播放时长并且播放不要刷新,丝滑一点,有没有这样的一种神通???

有!!!

那就是今天的第二个主角,mediaSource

mediaSource 实现

mediaSource 简介

MediaSource 是一个 Web API,用于在浏览器中动态生成媒体流,从而实现实时音频和视频流的播放。它允许您通过 JavaScript 代码来控制媒体数据的生成和传输,从而创建自定义的流媒体播放体验。

主要用途之一是实现流媒体的逐段加载,这对于大型视频、直播等场景非常有用。通过 MediaSource,您可以控制媒体片段的加载、缓冲和播放,从而实现更灵活的流媒体处理。

来自 chatgpt 的回答

前端代码实现

这里的后端代码不用动,我们只需要改变一下前端代码就行,看代码。

const rangeVideo = () => {
  const totalSize = 9350042;
  const chunkSize = 1000000;
  const numChunks = Math.ceil(totalSize / chunkSize);
  let index = 0;

  const assetURL = "url";
  var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';

  if ("MediaSource" in window && MediaSource.isTypeSupported(mimeCodec)) {
    var mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener("sourceopen", sourceOpen);
  } else {
    console.error("Unsupported MIME type or codec: ", mimeCodec);
  }

  function sourceOpen(e) {
    var mediaSource = e.target;
    var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);

    const send = () => {
      if (index >= numChunks) {
        sourceBuffer.addEventListener("updateend", function (_) {
          mediaSource.endOfStream();
        });
      } else {
        const start = index * chunkSize;
        const end = Math.min(start + chunkSize - 1, totalSize - 1);
        fetch(assetURL, {
          headers: {
            Range: `bytes=${start}-${end}`,
            responseType: "arraybuffer",
          },
        }).then(async (response) => {
          response = await response.arrayBuffer();
          index++;
          sourceBuffer.appendBuffer(response);
          send();
          video.play();
        });
      }
    };

    send();
  }
};

可以看见代码的整体思路还是一样的,只是多了一下 mediaSource 的事件,还有数据的拼接方式变了,请求的发送还是一样的代码。

效果

jjm.gif

丝滑,完美!!!

这效果,说实话已经和我预想中的一模一样了。

没有一点卡顿,没有一点不好的感觉!!!

分析

这里主要是对 mediaSource 的分析。

  • 我们首先创建了一个 MediaSource 对象,并将其 URL 赋给了 <video> 元素的 src 属性
  • sourceopen 事件中,我们添加了一个 SourceBuffer 对象,用于加载视频片段数据
  • 最后,我们通过 fetch 获取视频片段数据,将其添加到 SourceBuffer 中,然后开始播放视频

MediaSource 的 readyState 属性表示了其状态,分别有 closed,open,ended 三种

  • closed:MS 没有和媒体元素如 video 相关联。MS 刚创建时就是该状态。
  • open:source 打开,并且准备接受通过 sourceBuffer.appendBuffer 添加的数据。
  • ended:当 endOfStream()执行完成,会变为该状态。

不足

这么完美的效果也有不足的地方吗?

很遗憾的说,确实有,那就是不是随便什么 mp4 视频都支持这样做的。

mediaSource MDN 里面有一段例子代码的 MDN 我一开始就把这个代码跑出来,然后用了自己的一个 mp4 视频,结果报错:

Uncaught DOMException: Failed to execute ‘endOfStream’ on ‘MediaSource’: The MediaSource’s readyState is not ‘open’.

上网找了很多资料才找到了答案,就在下面的参考文章里,不是什么 mp4 都支持这样玩的,得是 fragment mp4,简称 fmp4,普通的不行。 我用的例子就是从网上找到的 fmp4 资源,可用的视频链接,需要的可以自行下载。

fragment mp4

那么到了这里,我们只需要把普通的 mp4 视频转化成 fmp4,不就解决了所有的问题。那么如何操作,这里给出两个资源。

  1. bento4

image.png 2. ffmpeg 使用方法见参考文章 4

参考文章

  1. MSE(Media Source Extensions)的一点尝试
  2. 通过调试技术,我理清了 b 站视频播放很快的原理
  3. fragment MP4
  4. fragment mp4 转换
  5. 示例视频