forked from oct16/TimeCat
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
275 additions
and
216 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,169 @@ | ||
## @oct16/WebReplay | ||
### HTML5录屏器的黑魔法 | ||
|
||
如果你爱打游戏,一定打过魔兽争霸3(暴露年纪🤣),你也许会游戏导出的录像文件感到好奇,明明打了一个小时游戏,为什么录像才几百KB而已。不过很快你又发现另一个问题,在每次导入录像的时候需要重新加载一次地图,否则就不能播放。 | ||
|
||
突然恍然大悟,原来如此啊! | ||
|
||
录像记录的数据不是一个视频文件,而是带着时间戳的一系列动作,导入地图的时候,实际相当于初始了一个状态,在这个状态的基础上,只需要对之前的动作进行还原,也就还原的之前的游戏过程。 | ||
> 相关问题:[《魔兽争霸》的录像,为什么长达半小时的录像大小只有几百 KB?](https://www.zhihu.com/question/25431134/answer/30917935) | ||
|
||
但是这样有什么好处呢? | ||
|
||
首先是对于一个录像,这样的方式极大程度的减小了体积,假设我们需要录一个小时的1080p24f视频,在视频未压缩的情况下 | ||
``` | ||
总帧数 = 3600s * 24 = 86400frame | ||
假设每个逻辑像素用RGB三基色表示,每个基色8bits(256色)的话 | ||
帧大小 = (1920 * 1080)pixels * 8bits * 3 = 49766400bits | ||
换算成KB是 49766400bits / 8 / 1024 = 6075KB | ||
总视频体积 = 6075KB * 86400 = 524880000KB = 500GB | ||
``` | ||
|
||
所以对比传统的视频录像方法,假设录像是500KB,那么理论上体积上缩小了大约 500GB / 500KB = **1000000倍** | ||
|
||
Web录屏器其实也借鉴这样的一种思路,工程上一般称之为Operations Log, 本质上他的实现也是通过记录一系列的浏览器事件数据,通过浏览器引擎重新渲染,还原了之前的操作过程,也就达到了“录屏器”的效果 | ||
从实际来看,对比采用最新H265压缩比达到300倍的视频,体积上至少也能节省200倍以上 | ||
|
||
对比传统的视频流,它的优势也是显而易见的 | ||
|
||
1. 极大程度减小录像文件体积 | ||
2. 极低的CPU与内存占用比率 | ||
3. 无损显示,可以进行无极缩放,窗口自适应等 | ||
4. 非常灵活的时间跳转,几乎无法感知的缓冲时间 | ||
5. 所有信息都是活的,文本图片可以复制,链接可以点击,鼠标可以滚动 | ||
6. 可以方便的录制声音,并让声音和画面同步,还以类似YouTube那样把声音翻译成字幕 | ||
7. 方便进行视频细节的修改,例如显示的内容进行脱敏,生成热力图等 | ||
8. 记录的序列化数据,十分利于知乎进行数据分析 | ||
|
||
|
||
那么问题来了:为什么要对网页录像?有哪些应用场景? | ||
|
||
我能想到的主要有以下几个方面 | ||
|
||
1. 异常监控系统,例如[LogRocket](https://logrocket.com/),可以理解他是一个整合了Sentry + 录屏器的工具,能回放网页错误时的图形界面与数据日志,从而帮助Debug | ||
2. 记录用户的行为进行分析,例如[MouseFlow](https://mouseflow.com/)。甚至还可以是直播的方式[LiveSession](https://livesession.io/),“连接”到用户的浏览中,看看用户是怎么使用网站的 | ||
3. 对客服人员的监控,例如阿里的有十万级别的客服小二人员分散在全国各地,需要对他们的服务过程进行7x24小时的录屏,在这个数量级上的对监控的性能要求就非常高了,阿里内部的工具叫`XReplay` | ||
4. 一些黑产也可以利用类似的技术,这个就不细说了 | ||
|
||
--- | ||
### Web录屏器的技术细节 | ||
|
||
##### 对DOM进行快照 | ||
|
||
通过DOM的API可以很轻易的拿到页面的节点数据,但是对于我们的需求而言,显而HTMLElement提供的数据太冗余了,这一步可以参考VirtualDom的设计,把信息精简一下 | ||
|
||
```ts | ||
interface VNode{ | ||
id: number | ||
tag: string | ||
attrs: { [key: string]: string } | ||
children: (VNode | string )[] | ||
} | ||
``` | ||
|
||
对DOM进行深度遍历后,DOM被映射成了VNode类型节点,需要记录的 Node 主要是两种类型 ``ELEMENT_NODE`` 和 ``TEXT_NODE``,之后在播放时,只需要对VNode进行解析,就很轻松的还原成记录时的状态了 | ||
|
||
在这过程中,有一些节点和属性需要特殊处理,例如 | ||
|
||
- `InputElement`等类型的Value是从DOM无法获取的,需要从节点中对象中获取 | ||
- `script`标签的内容由于之后不会去执行,所以可以直接`skip`或者标记为`noscript` | ||
- `SVG`可以直接获取,但是它本身以及它的子元素重新转换为DOM的时候需要使用`createElementNS("http://www.w3.org/2000/svg", tagName)`的方法创建元素 | ||
- `src`或`href`属性如果是相对轨迹,需要把他们转换为绝对轨迹 | ||
... | ||
|
||
##### 记录影响页面元素变化的Action | ||
|
||
DOM的变化可以使用`MutationObserver` | ||
```ts | ||
const observer = new MutationObserver((mutationRecords, observer) => { | ||
// Record the data | ||
}) | ||
observer.observe(target, options) | ||
``` | ||
|
||
通过它可以监听到一系列的操作 | ||
``` | ||
- Add Node Action | ||
- Delete Node Action | ||
- Change Attribute Action | ||
- Scroll Action | ||
- Change Location Action | ||
... | ||
``` | ||
通过`document.addEventListener` 的 `mouseMove` 和 `click` 事件记录鼠标动作 | ||
|
||
对于 `mouseMove` 事件,在移动的过程中会频繁的触发,产生很容冗余的数据,这样的数据会浪费很多的空间,因此对于鼠标的轨迹,我们只采集少量的关键点,最简单的办法是使用节流来减小事件产生的数据量,但是也有一些缺点: | ||
1. 截流的间隔中可能会丢失关键的鼠标坐标数据 | ||
2. 即时通过截流在移动距离足够长的时候任然会产生巨大的数据量,更好的办法是通过 `Spline Curves(样条曲线)` 函数来计算,就能很轻易的生成了一条曲线用来控制鼠标的移动 | ||
|
||
Input的变换我们可以通过`Node.addEventListener` 的 `input` `blur` `focus` 事件进行监听,但是这只对用户的行为发射事件,如果是通过JavaScript对Input标签进行赋值,我们是监听不到数据的变化的,这时我们可以通过`Object.defineProperty`来对InputElement对象的`value`属性进行劫持,在不影响目标赋值的情况下,把value新值转发到`input`事件中,统一处理Input状态变化 | ||
|
||
```ts | ||
function listenInputElement() { | ||
const inputProto = HTMLInputElement.prototype | ||
Object.defineProperty(inputProto, 'value', { | ||
set: function(value) { | ||
var newValue = arguments.length ? value : this.value | ||
var node = this.attributes.value | ||
if (!node || newValue !== node.value) { | ||
var event = document.createEvent('Event') | ||
event.initEvent('input', true, true) | ||
this.setAttribute('value', newValue) | ||
if (document.documentElement.contains(this)) { | ||
this.dispatchEvent(event) | ||
} | ||
} | ||
} | ||
}) | ||
} | ||
``` | ||
|
||
##### 通过样条曲线模拟鼠标轨迹 | ||
|
||
用户在网页中移动鼠标会产生很多`mouseMove`事件,通过 `const {x,y} = event.target` 获取到了轨迹的坐标与时间戳 | ||
|
||
假如我在页面上用鼠标划过一个💖的轨迹,可能会得到下图这样的坐标点 | ||
 | ||
但是对于录像这个业务场景来说,大部分场合我们并不要求100%还原精确的鼠标轨迹,我门只会关心两种情况: | ||
``` | ||
1. 鼠标在哪里点击? | ||
2. 鼠标在哪里停留? | ||
``` | ||
那么通过这个两个策略对鼠标轨迹进行精简后,画一个💖大约只需要6个点,通过样条曲线来模拟鼠标的虚拟轨迹,当 t = 0.2 的时候,就可以得到一个下图这样带着弧度的轨迹了 | ||
 | ||
|
||
|
||
##### 沙箱化提升安全 | ||
|
||
录制的内容有可能属于第三方提供,这意味着可能存在一定的风险,网站中可能有一些恶意的脚本并没有被我们完全过滤掉,例如:`<div onload="alert('something'); script..."></div>`,或者我们的播放器中的一些事件也可能对播放内容产生影响,这时候我们需要一个沙盒来隔离播放内容的环境,HTML5 提供的 iframe sandbox是不错的选择,这可以帮助我们轻易的隔离环境: | ||
``` | ||
- script脚本不能执行 | ||
- 不能发送ajax请求 | ||
- 不能使用本地存储,即localStorage,cookie等 | ||
- 不能创建新的弹窗和window, 比如window.open or target="_blank" | ||
- 不能发送表单 | ||
- 不能加载额外插件比如flash等 | ||
- 不能执行自动播放的tricky. 比如: autofocused, autoplay | ||
``` | ||
|
||
##### 数据块的压缩 | ||
|
||
|
||
|
||
##### 播放、跳转与快进 | ||
|
||
播放:播放器会内置一个精确的计时器,动作的数据存储在一个栈中,栈中的每一个对象就是一帧,通过RAF(requestAnimationFrame) 对数据帧的时间戳进行扫描从而得知下一帧在什么时间发生 | ||
|
||
暂停:通过cancelAnimationFrame暂停计时器 | ||
|
||
快进:加速采集速率的倍速 | ||
|
||
跳转:跳转相当于一个最快速的快进功能,但是如果跳转的距离太远,性能会非常差,这时需要按一定的距离对播放轴插入预先的snapshot,再从最近的snapshot进行跳转 | ||
|
||
##### 数据上传 | ||
|
||
对于客户端的数据,可以利用浏览器提供的IndexDB进行存储,毕竟IndexDB会比LocalStorage容量大得多,一般来说不少于 250MB,甚至没有上限,此外它使用object store存储,而且支持transaction,另外很重要的一点它是异步的,意味着不会阻塞录屏器的运行 | ||
之后数据可以通过WebSocket或其他方式持续上传到OSS服务器中,由于数据是分块进行传输的,在同步之后还可以增加数据校验码来保证一致性避免错误 |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { record } from '@WebReplay/record' | ||
import { replay } from '@WebReplay/player' | ||
import { DataStore, SnapshotData } from '@WebReplay/snapshot' | ||
|
||
new Promise(resolve => { | ||
const indexDB = new DataStore('wr_db', 1, 'wr_data', () => { | ||
resolve(indexDB) | ||
}) | ||
}).then((indexDB: DataStore) => { | ||
record({ | ||
emitter: data => { | ||
indexDB.add(data) | ||
} | ||
}) | ||
const replayButton = document.getElementById('replay') | ||
if (replayButton) { | ||
replayButton.onclick = () => { | ||
indexDB.readAll((data: SnapshotData[]) => { | ||
replay(data) | ||
}) | ||
} | ||
} | ||
}) |
Oops, something went wrong.