赞
踩
生产负载调用rrWeb情况
前端调用保存录制使用固定地址,调用预生产服务进行保存
定时任务执行,设置地址配置为预生产地址
/rrWeb/saveRrWeb
1、首次录制,生成本次录制的唯一id
2、同一订单可能存在多个录制id
3、录制id在当前录制流程中唯一,关闭录制或关闭浏览器重新进入开始录制,会生成新的id
4、使用Redis记录录制id每次片段的顺序
5、保存数据到backTrackRecord、backTrackVideo表
6、保存录制数据到服务器中,保存目录/data/service/rrWeb/sava/录制id/片段数据
7、/data/service/rrWeb目录在sys_config表中配置
/job/convertVideo
1、查询backTrackRecord表中未合并过的数据
2、获取对应录制id
3、根据录制id获取/data/service/rrWeb/sava/录制id/目录下所有的片段数据
4、根据片段录制顺序,读取文件内容,写到/data/service/rrWeb/录制id.txt文件下
5、更新backTrackRecord状态
无
1、将/data/service/rrWeb/下对应的文件备份到history目录
2、使用rrvideo命令,将对应文件转换为视频,保存到/data/service/rrWeb/mp4下
3、转换过程超过2分钟的停止
4、转换完成删除/data/service/rrWeb/下对应文件
# 打开定时任务配置文件
crontab -e
# 在配置文件中写入定时任务的操作, 这里就是指定每天1点10分定时执行脚本,并把执行脚本的日志写入文件 crontabLoad.log
10 1 * * * sh /data/service/rrWeb/start.sh > /data/service/rrWeb/crontabLoad.log 2>&1
#!/bin/bash # 这个很重要,不引用环境变量,脚本执行rrvideo时会报错找不到命令 source /etc/profile #set -x INPUT_DIR=/data/service/rrWeb OUTPUT_DIR=/data/service/rrWeb/mp4 HISTORY_DIR=/data/service/rrWeb/history TIMEOUT=120 # 2分钟超时 time=$(date -d "7 minute ago" +"%Y-%m-%d %H:%M:%S") echo "${time} :开始执行rrWeb转视频" files=$(ls $INPUT_DIR/*.txt | wc -l); if [ "$files" != "0" ]; then for f in $INPUT_DIR/*.txt; do filename=$(basename "$f") echo "当前转换filename:${filename}" # 复制到history目录 cp "$f" "$HISTORY_DIR/$filename" nohup rrvideo --input "$f" --config "$INPUT_DIR/rrvideo.config" --output "$OUTPUT_DIR/${filename%.*}.mp4" & pid=$! timeout=$TIMEOUT while ps -p $pid > /dev/null; do sleep 1 timeout=$((timeout-1)) if [ $timeout -eq 0 ]; then kill -9 $pid echo "killed ${filename}" break fi done if [ $timeout -ne 0 ]; then # 删除txt文件 rm "$f" fi echo "Finished $f to $filename.mp4" done else echo "没有要转换的文件" fi # 关闭无头浏览器进程 PID=`ps -ef | grep puppeteer | awk '{print $2}'` echo "得到进程ID:${PID}" echo "结束进程" for id in ${PID} do kill -9 ${id} echo "killed ${id}" done echo "结束进程完成" echo "执行rrWeb转视频结束"
rrvideo.config
{
"width":1280,
"height":720,
"speed": 1,
"skipInactive": true,
"mouseTail": {
"strokeStyle": "green",
"lineWidth": 2
},
"startDelayTime": 1000
}
/job/uploadVideoToObs
1、查询backTrackVideo表中未上传OBS的数据
2、3天内还未上传到OBS,进行告警
3、根据录制id获取/data/service/rrWeb/map目录下的视频文件
4、获取视频流,上传OBS
5、 更新backTrackVideo表OBS视频地址
node官网下载地址:https://nodejs.org/en/download/
下载对应的包:node-v14.17.6-linux-x64.tar.gz
或者使用命令下载
wget https://nodejs.org/download/release/v14.17.6/node-v14.17.6-linux-x64.tar.gz
将文件放到预生产/opt/nodejs下
# 解压文件 tar zxvf node-v14.17.6-linux-x64.tar.gz ## 更改名称 mv node-v14.17.6-linux-x64 node14.17.6 ## 赋予执行权限 chmod 777 node14.17.6/ # 配置环境变量 vim /etc/profile # 在文件中增加配置,保存后退出 export NODE_HOME=/opt/nodejs/node14.17.6 export PATH=$NODE_HOME/bin:$PATH # 配置生效 source /etc/profile #验证是否安装成功 node -v npm -v
ffmpeg官方网站:FFmpeg ;在官方网站内也可以下载ffmpeg的源码以及ffmpeg编译好的库文件;官方网站首页如下图;点击下图绿色按键"Download"可以进入ffmpeg的下载页面;在官方网站首页的左侧有几个子目录,其中包含下载目录Download和使用帮助文档目录Documentation。
在点击Download后可以进入ffmpeg的下载页面,如下图;通过点击Download Source Code就可以下载最新的ffmpeg源代码;也可以下载Linux/Windows/MacOS这三种平台下ffmpeg的可执行程序和lib库文件,如下图红色框。
cd /data/service # 创建文件夹 mkdir ffmpeg # 上传下载的文件ffmpeg-release-i686-static.tar到此文件夹下 # 解压 tar -xvf ffmpeg-release-i686-static.tar ## 更改名称 mv ffmpeg-6.0-i686-static ffmpeg cd ffmpeg # 查看版本 ./ffmpeg -version # 配置环境变量 vim /etc/profile # 在文件中增加配置,保存后退出 export PATH=$PATH:/data/service/ffmpeg/ffmpeg # 配置生效 source /etc/profile # 创建软连接 ln -s /data/service/ffmpeg/ffmpeg ffmpeg # 查看环境变量是否生效 ffmpeg -version
# 全局安装rrvideo npm i -g rrvideo --unsafe-perm=true # 到puppeteer目录 cd /opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer # 安装依赖 npm install # 安装成功后执行命令转换视频 rrvideo --input rrWeb_48234910066649106.txt --output rrWeb_48234910066649106.mp4 # 报错 Failed to transform this session. Error: Failed to launch the browser process! /opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome: error while loading shared libraries: libX11-xcb.so.1: cannot open shared object file: No such file or directory TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md at onClose (/opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:193:20) at Interface.<anonymous> (/opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:183:68) at Interface.emit (events.js:412:35) at Interface.close (readline.js:451:8) at Socket.onend (readline.js:224:10) at Socket.emit (events.js:412:35) at endReadableNT (internal/streams/readable.js:1317:12) at processTicksAndRejections (internal/process/task_queues.js:82:21) # 安装缺失的库文件 yum install pango.x86_64 libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXtst.x86_64 cups-libs.x86_64 libXScrnSaver.x86_64 libXrandr.x86_64 GConf2.x86_64 alsa-lib.x86_64 atk.x86_64 gtk3.x86_64 -y yum update nss -y yum install libX11-devel --nogpg yum install libgbm* yum install libdrm* # 再次执行转换视频命令 rrvideo --input rrWeb_48234910066649106.txt --output rrWeb_48234910066649106.mp4 # 报错 Failed to transform this session. Error: Failed to launch the browser process! /opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome: error while loading shared libraries: libgbm.so.1: cannot open shared object file: No such file or directory TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md at onClose (/opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:193:20) at Interface.<anonymous> (/opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/lib/cjs/puppeteer/node/BrowserRunner.js:183:68) at Interface.emit (events.js:412:35) at Interface.close (readline.js:451:8) at Socket.onend (readline.js:224:10) at Socket.emit (events.js:412:35) at endReadableNT (internal/streams/readable.js:1317:12) at processTicksAndRejections (internal/process/task_queues.js:82:21) # 确认报错为缺少的库文件,libgbm.so.1 # 或使用ldd /opt/nodejs/node14.17.6/lib/node_modules/rrvideo/node_modules/puppeteer/.local-chromium/linux-818858/chrome-linux/chrome 命令 查看缺失的库文件 # 去安装过libgbm.so.1的服务器,查看libgbm.so.1通过安装什么软件,此次我是去测试环境执行 # 测试环境执行如下命令 [root@test-webapp-svr20 ffmpeg]# rpm -qf /lib64/libgbm.so.1 mesa-libgbm-21.1.5-1.el8.x86_64 # 预生产执行 yum install mesa-libgbm-21.1.5-1.el8.x86_64 # 安装成功后执行转换命令,转换成功表示安装完成 rrvideo --input rrWeb_48234910066649106.txt --output rrWeb_48234910066649106.mp4
缺少其他库文件解决方法
# 如缺少libpcre.so.1文件 一种是安装libpcre.so.1对应的软件, 一种是获取libpcre.so.1库文件并放置在 /lib64目录下。 最后一种是获取libpcre.so.1库文件库文件并上传至服务器A任意目录下上并设置LD_LIBRARY_PATH变量 方法一:获取软件并设置LD_LIBRARY_PATH变量方法。如下: Step1:从服务器B上下载libpcre.so.1对应软件,上传至服务器A上任意目录下。如/opt。 Step2:设置LD_LIBRARY_PATH变量。执行export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt。说明:LD_LIBRARY_PATH是Linux环境变量名,该环境变量主要用于指定查找共享库(动态链接库)时除了默认路径之外的其他路径。 Step3:重新执行程序,问题解决。 方法二:获取软件并放置指定目录下。如下: Step1:从服务器B上下载libpcre.so.1对应软件,上传至服务器A上的/lib64目录下。/lib64为步骤(2)中查出缺失文件的目录。 Step2:重新执行程序,问题解决。 方法三:安装libpcre.so.1对应的软件方法如下: Step1:在服务器B上执行rpm -qf /lib64/libpcre.so.1。 [root@www ~]# rpm -qf /lib64/libpcre.so.1 libpcre-3.2-21.el5 --> 说明libpcre.so.1文件是通过安装libpcre-3.2-21.el5获取。 Step2:查看Linux服务器系统版本,获取对应的镜像包,从而获取libpcre-3.2-21.el5.rpm安装软件 Step3:执行rpm -ivh libpcre-3.2-21.el5.rpm安装。 Step4:重新执行程序,问题解决。
rrvideo-main\src\index.ts
import * as fs from "fs"; import * as path from "path"; import { spawn } from "child_process"; import puppeteer from "puppeteer"; import type { eventWithTime } from "rrweb/typings/types"; import type { RRwebPlayerOptions } from "rrweb-player"; import type { Page, Browser } from "puppeteer"; const rrwebScriptPath = path.resolve( require.resolve("rrweb-player"), "../../dist/index.js" ); const rrwebStylePath = path.resolve(rrwebScriptPath, "../style.css"); const rrwebRaw = fs.readFileSync(rrwebScriptPath, "utf-8"); const rrwebStyle = fs.readFileSync(rrwebStylePath, "utf-8"); interface Config { // start playback delay time startDelayTime?: number, } function getHtml( events: Array<eventWithTime>, config?: Omit<RRwebPlayerOptions["props"] & Config, "events"> ): string { return ` <html> <head> <style>${rrwebStyle}</style> </head> <body> <script> ${rrwebRaw}; /*<!--*/ const events = ${JSON.stringify(events).replace( /<\/script>/g, "<\\/script>" )}; /*-->*/ const userConfig = ${config ? JSON.stringify(config) : {}}; window.replayer = new rrwebPlayer({ target: document.body, props: { events, showController: false, autoPlay: false, // autoPlay off by default ...userConfig }, }); window.replayer.addEventListener('finish', () => window.onReplayFinish()); let time = userConfig.startDelayTime || 1000 // start playback delay time, default 1000ms let start = fn => { setTimeout(() => { fn() }, time) } // It is recommended not to play auto by default. If the speed is not 1, the page block in the early stage of autoPlay will be blank if (userConfig.autoPlay) { start = fn => { fn() }; } start(() => { window.onReplayStart(); window.replayer.play(); }) </script> </body> </html> `; } type RRvideoConfig = { fps: number; headless: boolean; input: string; cb: (file: string, error: null | Error) => void; output: string; rrwebPlayer: Omit<RRwebPlayerOptions["props"] & Config, "events">; }; const defaultConfig: RRvideoConfig = { fps: 15, headless: true, input: "", cb: () => {}, output: "rrvideo-output.mp4", rrwebPlayer: {}, }; class RRvideo { private browser!: Browser; private page!: Page; private state: "idle" | "recording" | "closed" = "idle"; private config: RRvideoConfig; constructor(config?: Partial<RRvideoConfig> & { input: string }) { this.config = { fps: config?.fps || defaultConfig.fps, headless: config?.headless || defaultConfig.headless, input: config?.input || defaultConfig.input, cb: config?.cb || defaultConfig.cb, output: config?.output || defaultConfig.output, rrwebPlayer: config?.rrwebPlayer || defaultConfig.rrwebPlayer, }; } public async init() { try { // 定义puppeteer 相关配置可以参考:https://zhuanlan.zhihu.com/p/624900686 this.browser = await puppeteer.launch({ headless: this.config.headless, }); // 初始化时创建一个新页面 this.page = await this.browser.newPage(); await this.page.goto("about:blank"); // 页面开始时执行的方法 await this.page.exposeFunction("onReplayStart", () => { this.startRecording(); }); // 页面结束时执行的方法 await this.page.exposeFunction("onReplayFinish", () => { this.finishRecording(); }); const eventsPath = path.isAbsolute(this.config.input) ? this.config.input : path.resolve(process.cwd(), this.config.input); const events = JSON.parse(fs.readFileSync(eventsPath, "utf-8")); // 向页面中传参,传入录制的dom数据和配置 await this.page.setContent(getHtml(events, this.config.rrwebPlayer)); } catch (error) { this.config.cb("", error); } } private async startRecording() { this.state = "recording"; let wrapperSelector = ".replayer-wrapper"; if (this.config.rrwebPlayer.width && this.config.rrwebPlayer.height) { wrapperSelector = ".rr-player"; } const wrapperEl = await this.page.$(wrapperSelector); if (!wrapperEl) { throw new Error("failed to get replayer element"); } // start ffmpeg const args = [ // fps "-framerate", this.config.fps.toString(), // input "-f", "image2pipe", "-i", "-", // output "-y", this.config.output, ]; const ffmpegProcess = spawn("ffmpeg", args); ffmpegProcess.stderr.setEncoding("utf-8"); ffmpegProcess.stderr.on("data", console.log); let processError: Error | null = null; const timer = setInterval(async () => { if (this.state === "recording" && !processError) { try { const buffer = await wrapperEl.screenshot({ encoding: "binary", }); ffmpegProcess.stdin.write(buffer); } catch (error) { // ignore } } else { clearInterval(timer); if (this.state === "closed" && !processError) { ffmpegProcess.stdin.end(); } } }, 1000 / this.config.fps); const outputPath = path.isAbsolute(this.config.output) ? this.config.output : path.resolve(process.cwd(), this.config.output); ffmpegProcess.on("close", () => { if (processError) { return; } this.config.cb(outputPath, null); }); ffmpegProcess.on("error", (error) => { if (processError) { return; } processError = error; this.config.cb(outputPath, error); }); ffmpegProcess.stdin.on("error", (error) => { if (processError) { return; } processError = error; this.config.cb(outputPath, error); }); } private async finishRecording() { this.state = "closed"; await this.browser.close(); } } export function transformToVideo( config: Partial<RRvideoConfig> & { input: string } ): Promise<string> { return new Promise((resolve, reject) => { const rrvideo = new RRvideo({ ...config, cb(file, error) { if (error) { return reject(error); } resolve(file); }, }); rrvideo.init(); }); }
// 在index.ts中的public async init()方法增加
await this.page.setDefaultNavigationTimeout(0);
全方法
public async init() { try { this.browser = await puppeteer.launch({ headless: this.config.headless, }); this.page = await this.browser.newPage(); // 增加设置超时时间为0 await this.page.setDefaultNavigationTimeout(0); await this.page.goto("about:blank"); await this.page.exposeFunction("onReplayStart", () => { this.startRecording(); }); await this.page.exposeFunction("onReplayFinish", () => { this.finishRecording(); }); const eventsPath = path.isAbsolute(this.config.input) ? this.config.input : path.resolve(process.cwd(), this.config.input); const events = JSON.parse(fs.readFileSync(eventsPath, "utf-8")); await this.page.setContent(getHtml(events, this.config.rrwebPlayer)); } catch (error) { this.config.cb("", error); } }
白边问题为puppeteer默认打开浏览器的大小与rrvideo播放器的大小冲突导致
puppeteer打开的页面默认的窗口大小是800*600,与rrWeb-player的窗口大小不符合导致的白边问题
// 在index.ts中的public async init()方法增加 窗口可以设置1280*720 或者1920*1080
await this.page.setViewport({width: 1280,height: 720,deviceScaleFactor: 1});
此大小需要与rrvideo的rrvideo.config配置文件相同
rrvideo.config
{
"width":1280,
"height":720,
"speed": 1,
"skipInactive": true,
"mouseTail": {
"strokeStyle": "green",
"lineWidth": 2
},
"startDelayTime": 1000
}
全方法
public async init() { try { this.browser = await puppeteer.launch({ headless: this.config.headless, }); this.page = await this.browser.newPage(); // 增加设置超时时间为0 await this.page.setDefaultNavigationTimeout(0); // 设置puppeteer打开谷歌浏览器的大小 await this.page.setViewport({width: 1280,height: 720,deviceScaleFactor: 1}); await this.page.goto("about:blank"); await this.page.exposeFunction("onReplayStart", () => { this.startRecording(); }); await this.page.exposeFunction("onReplayFinish", () => { this.finishRecording(); }); const eventsPath = path.isAbsolute(this.config.input) ? this.config.input : path.resolve(process.cwd(), this.config.input); const events = JSON.parse(fs.readFileSync(eventsPath, "utf-8")); await this.page.setContent(getHtml(events, this.config.rrwebPlayer)); } catch (error) { this.config.cb("", error); } }
转换速度
// 在index.ts中的RRvideoConfig中,将fps由10改为15
const defaultConfig: RRvideoConfig = {
fps:15,
headless: true,
input: "",
cb: () => {},
output: "rrvideo-output.mp4",
rrwebPlayer: {},
};
清晰度
// 在index.ts中的startRecording方法,调整ffmpeg的配置 // 原 const args = [ // fps "-framerate", this.config.fps.toString(), // input "-f", "image2pipe", "-i", "-", // output "-y", this.config.output, ]; // 改为 args = [ // fps "-framerate", this.config.fps.toString(), // input "-f", "image2pipe", "-i", "-", // output "-y", "-b:v", "2000k", this.config.output, ];
ffmpeg配置含义参考:
https://zhuanlan.zhihu.com/p/145312133
https://wenku.baidu.com/view/45b9f4a51a5f312b3169a45177232f60dccce749.html?wkts=1701760052153&bdQuery=ffmpeg+%E5%8F%82%E6%95%B0%E9%85%8D%E7%BD%AE-s
本地修改完index.ts代码后,需要将其编译为js文件,上传到服务器中
上传路径:nodejs目录下安装的rrvideo下build文件夹
以测试环境为例:/opt/nodejs/node14.17.6/lib/node_modules/rrvideo/build/index.js
将本地反编译的文件替换上面目录的index.js即可
.ts转为js
ts文件是TypeScript (一种JavaScript的超集)文件,要将其编译成,js文件,你需要使用TypeScript编译器.以下是使用TypeScript编译器编译.ts文件的步骤:
npm instal1 -g typescript
这将安装TypeScript编译器并在你的系统上设置一个类型化的命令tsc。
tsc index.ts
只要你使用rrWeb-player播放的内容和最终转换视频后的内容不一致,都是此问题导致
此问题的原因为rrvideo的版本没有人维护了,所使用的rrWeb-player版本很低,所以播放的时候有很多问题
找到你的rrvideo的位置,打开package.json查看rrWeb-player版本
cat /opt/nodejs/node14.17.6/lib/node_modules/rrvideo/package.json (你的rrvideo安装的地址)
{ "_from": "rrvideo", "_id": "rrvideo@0.2.1", "_inBundle": false, "_integrity": "sha512-EumIkBkXq+C2Ki6MKXYH3bxik5kTnZWn1IO6YmdJrLXHqgoPla7XUib0HpITE8UevFMq8xufXuo0ElHdwD5AZQ==", "_location": "/rrvideo", "_phantomChildren": {}, "_requested": { "type": "tag", "registry": true, "raw": "rrvideo", "name": "rrvideo", "escapedName": "rrvideo", "rawSpec": "", "saveSpec": null, "fetchSpec": "latest" }, "_requiredBy": [ "#USER" ], "_resolved": "https://registry.npmjs.org/rrvideo/-/rrvideo-0.2.1.tgz", "_shasum": "8849ead66853621884e21d3e254f33e18ca93378", "_spec": "rrvideo", "_where": "/opt/nodejs/node14.17.6/lib", "author": { "name": "yanzhen@smartx.com" }, "bin": { "rrvideo": "build/cli.js" }, "bundleDependencies": false, "dependencies": { "@types/minimist": "^1.2.1", "@types/puppeteer": "^5.4.0", "minimist": "^1.2.5", "puppeteer": "^5.4.1", "rrweb-player": "^0.6.5", "typescript": "^4.0.5" }, "deprecated": false, "description": "transform rrweb session into video", "files": [ "build" ], "license": "MIT", "main": "build/index.js", "name": "rrvideo", "scripts": { "build": "tsc", "prepublish": "yarn build", "test": "test" }, "version": "0.2.1" }
可以看到,rrvideo引用的rrWeb-player版本为0.6.5
修改此内容为你的rrWeb-player对应版本,我的是1.0.0-alpha.4
修改后执行
# 在/opt/nodejs/node14.17.6/lib/node_modules/rrvideo/下执行
npm install
# 若下载太慢,切换淘宝镜像(我太贴心了)
npm config set registry https://registry.npm.taobao.org
执行后重新转换视频,转换的视频和本地使用rrWeb-player播放的效果一致
吐槽:rrvideo好像很长时间没有人维护了,一堆问题,按照我的方式将它的代码优化后,基本使用时完全没有问题的。github上还有很多人的提问都没有回答,感觉是没人维护了,所以这些改动代码我没有提上去,大家有时间的可以将改好的代码提交上去,或者回答下github上的问题
操作步骤
rrvideo中index.js地址
#找到你安装的rrvideo地址
# 以下是我的地址
[root@test-webapp-svr20 rrvideo]# cd /opt/nodejs/node14.17.6/lib/node_modules/rrvideo
[root@test-webapp-svr20 rrvideo]# ll
total 44
-rw-r--r-- 1 root root 978 Oct 26 1985 README.md
-rw-r--r-- 1 root root 971 Oct 26 1985 README.zh_CN.md
drwxr-xr-x 2 root root 4096 Dec 13 16:42 build
drwxr-xr-x 72 root root 4096 Dec 14 15:21 node_modules
-rw-r--r-- 1 root root 22561 Dec 14 15:21 package-lock.json
-rw-r--r-- 1 root root 1307 Dec 14 15:16 package.json
[root@test-webapp-svr20 rrvideo]# cd build
[root@test-webapp-svr20 rrvideo]# vim index.js
下面分享我修改好后的index.js,替换掉原rrvideo的即可,喜欢的可以点个收藏,谢谢~
"use strict"; var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (_) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.transformToVideo = void 0; var fs = __importStar(require("fs")); var path = __importStar(require("path")); var child_process_1 = require("child_process"); var puppeteer_1 = __importDefault(require("puppeteer")); var rrwebScriptPath = path.resolve(require.resolve("rrweb-player"), "../../dist/index.js"); var rrwebStylePath = path.resolve(rrwebScriptPath, "../style.css"); var rrwebRaw = fs.readFileSync(rrwebScriptPath, "utf-8"); var rrwebStyle = fs.readFileSync(rrwebStylePath, "utf-8"); function getHtml(events, config) { return "\n<html>\n <head>\n <style>" + rrwebStyle + "</style>\n </head>\n <body>\n <script>\n " + rrwebRaw + ";\n /*<!--*/\n const events = " + JSON.stringify(events).replace(/<\/script>/g, "<\\/script>") + ";\n /*-->*/\n const userConfig = " + (config ? JSON.stringify(config) : {}) + ";\n window.replayer = new rrwebPlayer({\n target: document.body,\n props: {\n events,\n showController: false,\n ...userConfig\n },\n });\n window.onReplayStart();\n window.replayer.play();\n window.replayer.addEventListener('finish', () => window.onReplayFinish());\n </script>\n </body>\n</html>\n"; } var defaultConfig = { fps: 10, headless: true, input: "", cb: function () { }, output: "rrvideo-output.mp4", rrwebPlayer: {}, }; var RRvideo = /** @class */ (function () { function RRvideo(config) { this.state = "idle"; this.config = { fps: (config === null || config === void 0 ? void 0 : config.fps) || defaultConfig.fps, headless: (config === null || config === void 0 ? void 0 : config.headless) || defaultConfig.headless, input: (config === null || config === void 0 ? void 0 : config.input) || defaultConfig.input, cb: (config === null || config === void 0 ? void 0 : config.cb) || defaultConfig.cb, output: (config === null || config === void 0 ? void 0 : config.output) || defaultConfig.output, rrwebPlayer: (config === null || config === void 0 ? void 0 : config.rrwebPlayer) || defaultConfig.rrwebPlayer, }; } RRvideo.prototype.init = function () { return __awaiter(this, void 0, void 0, function () { var _a, _b, eventsPath, events, error_1; var _this = this; return __generator(this, function (_c) { switch (_c.label) { case 0: _c.trys.push([0, 8, , 9]); _a = this; return [4 /*yield*/, puppeteer_1.default.launch({ headless: this.config.headless, defaultViewport: { width: 1920, height: 1080, }, })]; case 1: _a.browser = _c.sent(); _b = this; return [4 /*yield*/, this.browser.newPage()]; case 2: _b.page = _c.sent(); return [4 /*yield*/, this.page.setDefaultNavigationTimeout(0)]; case 3: _c.sent(); return [4 /*yield*/, this.page.goto("about:blank")]; case 4: _c.sent(); return [4 /*yield*/, this.page.exposeFunction("onReplayStart", function () { _this.startRecording(); })]; case 5: _c.sent(); return [4 /*yield*/, this.page.exposeFunction("onReplayFinish", function () { _this.finishRecording(); })]; case 6: _c.sent(); eventsPath = path.isAbsolute(this.config.input) ? this.config.input : path.resolve(process.cwd(), this.config.input); events = JSON.parse(fs.readFileSync(eventsPath, "utf-8")); return [4 /*yield*/, this.page.setContent(getHtml(events, this.config.rrwebPlayer))]; case 7: _c.sent(); return [3 /*break*/, 9]; case 8: error_1 = _c.sent(); this.config.cb("", error_1); return [3 /*break*/, 9]; case 9: return [2 /*return*/]; } }); }); }; RRvideo.prototype.startRecording = function () { return __awaiter(this, void 0, void 0, function () { var wrapperSelector, wrapperEl, args, ffmpegProcess, processError, timer, outputPath; var _this = this; return __generator(this, function (_a) { switch (_a.label) { case 0: this.state = "recording"; wrapperSelector = ".replayer-wrapper"; if (this.config.rrwebPlayer.width && this.config.rrwebPlayer.height) { wrapperSelector = ".rr-player"; //wrapperSelector = ".replayer-wrapper"; } return [4 /*yield*/, this.page.$(wrapperSelector)]; case 1: wrapperEl = _a.sent(); if (!wrapperEl) { throw new Error("failed to get replayer element"); } args = [ // fps "-framerate", this.config.fps.toString(), // input "-f", "image2pipe", "-i", "-", // output "-y", //"-qscale", //"1", "-b:v", "2000k", // "-s", //"1280x720", this.config.output, ]; ffmpegProcess = child_process_1.spawn("ffmpeg", args); ffmpegProcess.stderr.setEncoding("utf-8"); ffmpegProcess.stderr.on("data", console.log); processError = null; timer = setInterval(function () { return __awaiter(_this, void 0, void 0, function () { var buffer, error_2; return __generator(this, function (_a) { switch (_a.label) { case 0: if (!(this.state === "recording" && !processError)) return [3 /*break*/, 5]; _a.label = 1; case 1: _a.trys.push([1, 3, , 4]); return [4 /*yield*/, wrapperEl.screenshot({ encoding: "binary", })]; case 2: buffer = _a.sent(); ffmpegProcess.stdin.write(buffer); return [3 /*break*/, 4]; case 3: error_2 = _a.sent(); return [3 /*break*/, 4]; case 4: return [3 /*break*/, 6]; case 5: clearInterval(timer); if (this.state === "closed" && !processError) { ffmpegProcess.stdin.end(); } _a.label = 6; case 6: return [2 /*return*/]; } }); }); }, 1000 / this.config.fps); outputPath = path.isAbsolute(this.config.output) ? this.config.output : path.resolve(process.cwd(), this.config.output); ffmpegProcess.on("close", function () { if (processError) { return; } _this.config.cb(outputPath, null); }); ffmpegProcess.on("error", function (error) { if (processError) { return; } processError = error; _this.config.cb(outputPath, error); }); ffmpegProcess.stdin.on("error", function (error) { if (processError) { return; } processError = error; _this.config.cb(outputPath, error); }); return [2 /*return*/]; } }); }); }; RRvideo.prototype.finishRecording = function () { return __awaiter(this, void 0, void 0, function () { return __generator(this, function (_a) { switch (_a.label) { case 0: this.state = "closed"; return [4 /*yield*/, this.browser.close()]; case 1: _a.sent(); return [2 /*return*/]; } }); }); }; return RRvideo; }()); function transformToVideo(config) { return new Promise(function (resolve, reject) { var rrvideo = new RRvideo(__assign(__assign({}, config), { cb: function (file, error) { if (error) { return reject(error); } resolve(file); } })); rrvideo.init(); }); } exports.transformToVideo = transformToVideo;
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。