利用puppeteer来录制网页操作导出 GIF 动图

利用puppeteer来录制网页操作导出 GIF 动图

先来看看效果,然后我们再说怎么实现。

可以看到完整的录制了我用puppeteer来编写的自动化脚本行为,那么是如何实现的呢,一开始我也没什么思路,看了一下开源的方案,大部分都是让使用一个 recoder 插件,看了一眼源码很简单,是利用ffmpeg 来实现的,还有很多对css动画录制的方案也是基于这个原理。

代码很短,一看就懂,page.screenshot一顿猛拍就完了。

一开始我也是这么做的,我把我每一步操作的结束都可以包装一次page.screenshot来截个图,然后多次操作的结果组合到一起就是完整的视频或者 GIF 了,可是这样真的很麻烦,而且screenshot本身的性能也很差。

其实换一个思路,我们知道puppeteer还支持 tracing 的 api,tracing 导出的结果是一个 json 文件里面是基于DevTools Protocol的,大家知道在性能测试阶段,DevTools 是支持截屏功能的,我们可以不可以利用这一点呢?

if (this._config.withGif) {
  await page.tracing.start({ path: tracefile, screenshots: true });
}
const res = await this._task.apply(null, [page, this._taskId]);
if (this._config.withGif) {
  await page.tracing.stop();
}

简单的代码就是这样,我们在开始我们的操作任务,也就是_task.apply 完成所有行为的前后,加入录制 gif 的功能,我们开启了 page.tracing.start 和 stop,这样就不必每一步里都去做screenshot了。

看一下导出来的文件格式大概是这样的:

里面 cat 的协议就是 DevTools的规范协议了,瞅了一眼,发现 snapshot 里的就是一段 base64的图片字符数据。

下面的代码我们这么来写:

 const tracing = JSON.parse(fs.readFileSync(tracefile, 'utf8'));
 const traceScreenshots = tracing.traceEvents.filter(
        (x: any, index: number) =>
          x.cat === 'disabled-by-default-devtools.screenshot' &&
          x.name === 'Screenshot' &&
          typeof x.args !== 'undefined' &&
          typeof x.args.snapshot !== 'undefined' &&
          index % 10 == 1
      );

先读取到 tracefile,然后过滤出来 screenshot 相关的数据,最后有个 index % 10 == 1 ,我们为了避免帧数过多,进行了抽帧操作,如果不抽帧一个10几 s 的行为录制完的 gif 大概有30多 MB,这个是接受不了的,后边还有除了抽帧之外的其他优化操作,比如把每张图片进行整体的cover 还有 resize 操作,还有对 gif 的 quality 设置等。

代码简单分几步说一下,我们首先获取视窗大小,然后创建一个新的 gifencode 实例,进行合并 gif 的前置操作:

 const viewport = page.viewport();
 let file = fs.createWriteStream(giffile);
 const encoder = new GIFEncoder(viewport.width / 2, viewport.height / 2);
 encoder.setFrameRate(100);
 encoder.pipe(file);
 encoder.setQuality(1);
 encoder.writeHeader();
 encoder.setRepeat(0);

获取 page 的 viewport,然后我们创建一个可写流,来做本地的 gif 保存,然后再创建一个新的 gifencoder 实例,设置高宽是实际高宽的一半,这是为了节约最后的体积。

然后设置了帧率是100ms,质量最低,无限重复。

  for (let index = 0; index < traceScreenshots.length; index++) {
        let snap = traceScreenshots[index];
        let base64string = snap.args.snapshot;
        let buffer = Buffer.from(base64string, 'base64');
        let image = await jimp.read(buffer);
        await image
          .cover(
            viewport.width,
            viewport.height,
            jimp.HORIZONTAL_ALIGN_LEFT | jimp.VERTICAL_ALIGN_TOP
          )
          .resize(viewport.width / 2, viewport.height / 2);
        encoder.addFrame(image.bitmap.data);
        encoder.read();
      }
      encoder.finish()

下面这个 for 循环可以详细说说,首先为什么不用 forEach,因为 forEach 中的 await 是有问题的,可以参考文末链接,这里就不细说了。

然后我们获取到每一帧数据,拿到 base64字符串,这里要转换成 buffer 进行操作,避免每一帧都本地存图,这里又用到了一个 jimp 的库,他的 read 方法支持读 buffer ,然后我们进行 cover 的裁切操作,强制每一张图片的高宽都是 viewport 的高宽,这里是因为 snapshot 有时候偶尔会出现实际尺寸和 viewport 不一致的情况,那么叠加 gif 的时候就会花屏,这里做了统一处理。

cover 之后再做一次裁切,resize 到一半,减少体积,gifencoder的文档后边大家可以自己去看一下,addFrame 的 data 是 pixels的格式,像素数据,所以正好 jimp 的 bitmap.data 就是 pixels 的,直接加入到帧就好了,因为怕数据过大,每次 addFrame 后都进行 read 操作,这里不解释太多,熟悉 stream 操作的同学应该都懂,最后调用finish 结束。

然后就是清理操作了,比如删除 json 文件什么的,这里如果大家对nodejs 的流操作比较熟悉,可以在 encoder.pipe 这里做一下变动,不 pipe 到一个可写流中,可以直接在 end 和 data 事件里把 buffer 处理好,最后一次性转成 base64的导给前端接口就行了。

这个就看各自的业务场景了,没什么太难的。

最后给出参考的一些文档和地址吧:

编辑于 2020-08-07 17:26