背景
研发过程中,对于部分偶发时间,异常监控系统仅仅只能告诉程序出错,而不能清晰告知错误的复现路径,因而录制视频的需求应运而生,例如可在在线考试 远程监考 屏幕共享 保险产品购买回溯等场景中应用到此技术,这也为以后的在线监控创造了条件。
类型:
有感录屏
什么是有感录屏
有感录屏一般指通过获得用户的授权或者通知用户接下来的操作将会被录制成视频,并且在录制过程中,用户有权关闭中断录屏。即无论在录屏前还是录屏的过程中,用户都始终能够决定录屏能否进行。
技术实现方案
基于 WebRTC 的有感录屏
常见的有感录屏方案主要是通过 WebRTC 录制。WebRTC 是一套基于音视轨的实时数据流传播的技术方案。由浏览器提供的原生 API navigator.mediaDevices.getDisplayMedia 方法实现提示用户选择和授权捕获展示的内容或窗口,进而将获取 stream (录制的屏幕音视流)。我们可以对 stream 进行转化处理,转成相对应的媒体数据,并将其数据存储。后续需要回溯该次录制内容时,则取出媒体数据进行播放。
具体的有感录屏流程如下:
实现初始化录屏和数据存储
使用navigator.mediaDevices.getDisplayMedia 初始化录屏,触发弹窗获取用户授权,效果图如下所示:
实现 WebRTC 初始化录屏核心代码如下:
const tracks = []; // 媒体数据
const options = {
mimeType : "video/webm; codecs = vp8", // 媒体格式
};
let mediaRecorder;
// 初始化请求用户授权监控
navigator.mediaDevices.getDisplayMedia(constraints).then((stream) => {
// 对音视流进行操作
startFunc(stream);
});
// 开始录制方法
function start(stream) {
// 创建 MediaRecorder 的实例对象,对指定的媒体流进行录制
mediaRecorder = new MediaRecorder(stream, options);
// 当生成媒体流数据时触发该事件,回调传参 event 指本次生成处理的媒体数据
mediaRecorder.ondataavailable = event => {
if(event?.data?.size > 0){
tracks.push(event.data); // 存储媒体数据
}
};
mediaRecorder.start();
console.log("************开始录制************")
};
// 结束录制方法
function stop() {
mediaRecorder.stop();
console.log("************录制结束************")
}
// 定义constraints数据类型
interface constraints {
audio: boolean | MediaTrackConstraints, // 指定是否请求音轨或者约束轨道属性值的对象
video: boolean | MediaTrackConstraints, // 指定是否请求视频轨道或者约束轨道属性值的对象
}
实现录屏回溯
获取该次录屏的媒体数据,可以将其转成 blob 对象,并且生成 blob对象的 url 字符串,再赋值 video.src 中,便可以回放到录制结果,回溯的视频效果如下:
录屏回溯方法的核心代码如下所示:
// 回放录制内容
function replay() {
const video = document.getElementById("video");
const blob = new Blob(tracks, {type : "video/webm"});
video.src = window.URL.createObjectURL(blob);
video.srcObject = null;
video.controls = true;
video.play();
}
浏览器兼容性
无感录屏
什么是无感录屏
无感录屏指在用户无感知的情况,对用户在页面上的操作进行录制。实现上与有感录制区别在于,无感录制通常是利用记录页面的 DOM 来进行录制。常见的有 canvas 截图绘制视频和 rrweb 录制等方案。
canvas截图绘制视频
用户在浏览页面时,可以通过 canvas 绘制多个 DOM 快照截图,再将多个截图合并成一段录屏视频。但是考虑到假设视频帧数为 30 帧,帧数代表着每秒所需的截图数量,为了视频的流畅和清晰,每张截图为 400 KB ,那么当视频长度为 1 分钟,则需要上传 703.125 MB 的资源,这么大的带宽浪费无疑会造成性能,甚至影响用户体验,不推荐使用,也不在此详细介绍本方案实现。
rrweb录制
是什么
rrweb (record and replay the web) 是一个对于 DOM 录制的支持性非常好,利用现代浏览器所提供的强大 API 录制并回放任意 web 界面中的用户操作,能够将页面 DOM 结构通过相应算法高效转换 JSON 数据的开源库。相比较于使用 canvas 绘制录屏,rrweb 在保证录制不掉帧的基础上,让网络传输数据更加快速和轻量化,极大地优化了网络性能。
核心思想
1)DOM快照
当我们想要查看用户在投保过程中某一时刻的页面状态时,我们只需要将那一刻的页面 dom 结构,以及页面中的 css 样式记录下来,然后在浏览器中重新渲染出来就能达到回溯的效果了。
const cloneDoc = document.documentElement.cloneNode(true); // 录制
document.replaceChild(cloneDoc, document.documentElement); // 回放
这样我们就实现了某一时刻 DOM 快照的功能。但是这个录制的 cloneDoc 还只是内存中的一个对象,并没有实现远程录制。
序列化
为了实现远程录制,我们需要将 cloneDoc 这个对象序列化成字符串,保存到服务端,然后在回放的时候从服务器上取出来,交给浏览器重新渲染。
const serializer = new XMLSerializer(); // XMLSerializer 是浏览器自带的 api,可以将 dom 对象序列化成 string
const str = serializer.serializeToString(cloneDoc);
document.documentElement.innerHTML = str;
至此,我们就完成了对用户界面某一时刻的远程录制功能。
2)定时快照
但是我们的目的是录制视频,只有一个 dom 快照显然是不够的。了解动画的同学都应该知道,动画是由每秒至少 24 帧的画面按顺序播放而产生的。在这里顺便科普一下这块的知识,当我们人眼观察到一个物体之后,这个画面会在我们的视网膜中停留 16.7ms 左右的的时间,专业名词叫做视觉停留,那么具体到给我们的感觉就是这个画面是“渐渐”消失的。
那么当我们在播放动画的时候,当第一帧画面在我们的视网膜中刚刚消失的时候,把第二帧放出来,那么给人的感觉就是画面是连续的,是在动的。但是动画里的人物动作给人的感觉还是有点卡顿、有点不自然的,为什么呢?我们来算一下: 1 秒/24 帧 = 41.7 毫秒,远远低于人眼可分辨的 16ms 的间隔,所以我们会觉得有点卡卡的。
为了达到更加流畅的画面,很多游戏和电影都会采用 60 帧/秒的速度来放映画面,因为 1 秒/60 帧 = 16.7ms,和人眼视觉停留的时间差不多,所以会感觉到画面很流畅。可以看一下你的电脑屏幕,一般的刷新率也是 60 帧。
扯远了,我们回归正题。由上面的知识我们知道,既然我们想要录制视频,那么至少每秒需要 24 帧的数据,也就是说 1000ms/24 帧 = 41.7 毫秒要 clone 一遍网页内容。
setInterval(() => {
const cloneDoc = document.documentElement.cloneNode(true)
const str = serializer.serializeToString(cloneDoc);
axios.post(address,str); // 保存到服务端
}, 41.7)
现在我们可以让画面动起来了,但是稍微细想便可知道这种方法根本行不通,原因有一下几点:
每秒 clone 24 次整个页面内容,对性能损耗巨大,严重影响用户体验
每秒要将 24 帧的页面内容上传到服务端,对网络开销也是巨大的
回放时,每秒要渲染 24 个完整的 html 内容,浏览器根本做不到这么快
还有,要是页面没变动,那么 24 帧的数据可能是完全一样的,根本没必要 clone 这个多次。
3)增量快照
基于以上定时快照的缺点,其实我们可以只在页面初始化完成之后 clone 一次完整的页面内容,等到页面有变动的时候,只记录变化的部分。这样一来,好处就显而易见了:
只记录变化的部分,比起记录整个网页要小的多。这样对网页的性能、网络的开销都会小很多。
我们只在页面有变动的时候才记录,这样一来,大量重复数据的问题也给解决了。
回放时,我们只需要首先将第一帧(完整的页面内容)先渲染出来,然后在按照记录的时间,按顺序将变化的部分渲染到页面。这样就可以像看视频一样来回溯用户的操作流程了。
举个例子,如上图所示,页面中一共有 4 个 div。页面有两次变化,第一次 dom2 变成了红色,第二次变化 dom4 变成了绿色。那么我们记录的数据大致是这个样子
var events = [
{完整的 html 内容},
{
id: 'dom2',
type: '#fff -> red'
},
{
id: 'dom4',
type: '#fff -> green'
}
]
记录的数据是一个数组,数组中有 3 个原始,第一个元素是完整的 html 内容,第二个元素描述的是 dom2 变成了红色,第三个元素描述的是 dom4 变成了绿色。 然后我们根据上诉记录的数据,就可以首先将 events[0] 渲染出来,然后执行 events[1] 将 dom2 变成红色,再将 dom4 变成绿色。 这样我们在理论上就完成了从页面的录制,到保存到远程服务器,再到最后回放,形成了功能上的完整的闭环。
4)MutationObserver
在上一步中,我们已经从理论上实现了录制和回放的功能。但是具体实现呢?我们怎么才能知道页面什么时候变化呢?变化了哪些东西呢? 实际上浏览器已经为我们提供了非常强大的 API,叫做 MutationObserver。它会以批量的方式返回 dom 的更新记录。 还是拿上面的例子来说明,改变一下 dom2 和 dom4 的背景色
setTimeout(() => {
let dom2 = document.getElementById("dom2");
dom2.style.background = "red";
let dom4 = document.getElementById("dom4");
dom4.style.background = "green";
}, 5000);
const callback = function (mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
console.log("子元素增加或者删除.");
} else if (mutation.type === "attributes") {
console.log("元素属性发生改变");
}
}
};
document.addEventListener("DOMContentLoaded", function () {
const observer = new MutationObserver(callback);
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
});
得到的回调数据是这样的
可以看到,MutationObserver 只记录了变化的 dom 元素(target),和变化的类型(type)。如此一来,我们便可以利用 MutationObserver 实现增量快照的思路。
5)可交互元素
利用 MutationObserver 我们可以记录元素的增加、删除、属性的更改,但是它没法跟踪像 input、textarea、select 这类可交互元素的输入。 对于这种可交互的元素,我们就需要通过监听 input 和 change 来记录输入的过程,这样我们就解决了用户手动输入的场景。 但是有些元素的值是通过程序直接设置的,这样是不会出发 input 和 change 事件的。这种情况下我们可以通过劫持对应属性的 setter 来达到监听的目的。
const input = document.getElementById("input");
Object.defineProperty(input, "value", {
get: function () {
console.log("获取 input 的值");
},
set: function (val) {
console.log("input 的值更新了");
},
});
input.value = 123;
以上就是浏览器录制和回放的大体思路,也是开源工具 rrweb(record replay web)的核心思想。当然 rrweb 中还记录了鼠标的移动轨迹、浏览器窗口的大小,增加了回放时的沙盒环境、时间校准等等,在这里不再赘述,有兴趣的同学可以自行查阅 rrweb 官网的介绍。
使用方法
以上篇幅主要介绍了 rrweb 录制和回放的核心思想,这里大致介绍一下它的使用方法。更多使用姿势请查看 rrweb 使用指南。 通过 npm 引入
npm install --save rrweb
录制
const events = []
let stopFn = rrweb.record({
emit(event) {
if (events.length > 100) {
// 当事件数量大于 100 时停止录制
stopFn();
// 将 events 序列化成字符串,并保持到服务器
}
},
});
回放
const events = []; //从服务端取出记录并反序列化成数组
const replayer = new rrweb.Replayer(events);
replayer.play();
静态资源时效问题
可以看到录制的数据中存在外链的图片,也就是说在我们利用录制的数据进行回放的时候,需要依赖这张图片。但是随着项目的迭代,这张图片很可能早已不在,这时我们再回放时,页面中的图片就会加载不出来。 其实不只是图片,外链的 css、字体文件等等都有这个问题。再回到文章开头提到的保险场景,保额信息就在网站内的一张海报上,客户可能会说:“我当时看到的保额明明是 150 万,怎么现在变成 100 万了?”,这时你要怎么证明当时海报上写的就是 100 万保额呢?
JSON 转视频
所以最稳妥的方案还是将 rrweb 录制的原始数据转换成视频,这样一来,不管网站怎么变化,迭代了多少版本,视频是不受影响的。 我的做法是通过 puppeteer 在服务端运行无头浏览器,在无头浏览器中回放录制的数据,然后每秒截取一定数量的图片,最后通过 ffmpeg 合成视频。下面是大致的流程图
帧率 我这里是一秒 50 帧,也就是说每隔 20ms 要截一张图。 截图时机 这里有个坑,puppeteer 截一张图的时间大概需要 300ms,假设页面在回放的过程中,我们使用 setInterval 每隔 20ms 执行一次截图,那么两次截图动作之间其实相隔了一次截图的时间,差了接近 300ms。第二帧我们想要截取的是视频地 20ms 的数据,可是回放页面已经播放到 320ms 处了。
暂停播放 为解决截图耗时所带来的影响,在每次截图之前,我将回放视频暂停到对应的时间点,这样截取到的就是我们想要的画面了
updateCanvas () {
if (this.imgIndex * 20 >= this.timeLength) {
this.stopCut(); // 事先计算整个视频需要截多少帧,截满了就结束
return;
}
// 截图
this.iframe.screenshot({
type: 'png',
encoding: 'binary',
}).then(buffer => {
this.readAble.push(buffer) //保存截图数据到可读流中
this.page.evaluate((data) => {
window.chromePlayer.pause(data * 20); // 将回放页中的视频暂停到对应时间点
}, this.imgIndex)
this.updateCanvas(this.imgIndex++)
})
}
输出视频
stopCut () {
this.readAble.push(null) // 截图完成后,需要给可读流一个 null,表示没有数据了
this.ffmpeg
.videoCodec('mpeg4') // 视频格式,这里我输出的是 mp4
.videoBitrate('1000k') // 每秒钟视频所占用的大小,这个是视频清晰度的关键指标
.inputFPS(50) // 帧率,这个是视频流畅度的关键指标,需要和每秒截图的数量保持一致
.on('end', () => {
console.log('\n 视频转换成功')
})
.on('error', (e) => {
console.log('error happend:' + e)
})
.save('./res.mp4') // 输出视频
}
网友评论