rrWeb可回溯录制+rrvideo转视频整体方案实施

rrWeb整体方案

1、整体流程

1.1、流程图

生产负载调用rrWeb情况

前端调用保存录制使用固定地址,调用预生产服务进行保存

定时任务执行,设置地址配置为预生产地址

1.2、功能说明

1.2.1 录制保存功能

  • 地址

/rrWeb/saveRrWeb

  • 功能

首次录制,生成本次录制的唯一id

同一订单可能存在多个录制id

录制id在当前录制流程中唯一,关闭录制或关闭浏览器重新进入开始录制,会生成新的id

使用Redis记录录制id每次片段的顺序

保存数据到backTrackRecord、backTrackVideo表

保存录制数据到服务器中,保存目录/data/service/rrWeb/sava/录制id/片段数据

/data/service/rrWeb目录在sys_config表中配置

扫描二维码关注公众号,回复: 17375602 查看本文章

1.2.2 定时合并数据

  • 地址

/job/convertVideo

  • 功能

查询backTrackRecord表中未合并过的数据

获取对应录制id

根据录制id获取/data/service/rrWeb/sava/录制id/目录下所有的片段数据

根据片段录制顺序,读取文件内容,写到/data/service/rrWeb/录制id.txt文件下

更新backTrackRecord状态

1.2.3 Linux下定时转换视频

  • 地址

  • 功能

将/data/service/rrWeb/下对应的文件备份到history目录

使用rrvideo命令,将对应文件转换为视频,保存到/data/service/rrWeb/mp4下

转换过程超过2分钟的停止

转换完成删除/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
}

1.2.4 定时上传OBS

  • 地址

/job/uploadVideoToObs

  • 功能

查询backTrackVideo表中未上传OBS的数据

3天内还未上传到OBS,进行告警

根据录制id获取/data/service/rrWeb/map目录下的视频文件

获取视频流,上传OBS

更新backTrackVideo表OBS视频地址

2、rrWeb环境搭建

2.1、安装nodejs

2.1.1、下载node.js

node官网下载地址:https://nodejs.org/en/download/

img

下载对应的包: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
2.1.2、上传文件到服务器

将文件放到预生产/opt/nodejs下

img

2.1.3、安装
# 解压文件
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

2.2、ffmpeg安装

2.2.1、下载

ffmpeg官方网站:FFmpeg ;在官方网站内也可以下载ffmpeg的源码以及ffmpeg编译好的库文件;官方网站首页如下图;点击下图绿色按键"Download"可以进入ffmpeg的下载页面;在官方网站首页的左侧有几个子目录,其中包含下载目录Download和使用帮助文档目录Documentation。

img

在点击Download后可以进入ffmpeg的下载页面,如下图;通过点击Download Source Code就可以下载最新的ffmpeg源代码;也可以下载Linux/Windows/MacOS这三种平台下ffmpeg的可执行程序和lib库文件,如下图红色框。

img

2.2.2、ffmpeg的安装
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

2.3、rrvideo安装

# 全局安装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:重新执行程序,问题解决。

2.4、使用rrvideo转换视频问题

2.4.1、rrvideo源码

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();
  });
}


2.4.2、问题一:rrvideo默认转换超时时间为30秒,调整为无超时时间,超时时间通过脚本进行控制
// 在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);
    }
  }
2.4.3、问题二:转换后的视频抖动问题,有白边

白边问题为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);
    }
  }
2.4.4、问题三:视频转换速度和清晰度问题

转换速度

// 在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

2.4.5、更改index.ts如何在服务器上生效

本地修改完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文件的步骤:

  • 1.安装TypeScript: 首先,确保你的系统上安装了Node.s。然后,在终端或命令提示符中运行以下命令今来全局安装TypeScript:
npm instal1 -g typescript

这将安装TypeScript编译器并在你的系统上设置一个类型化的命令tsc。

  • 2.编译ts文件:在终端或命令提示符中,导航到包含ts文件的目录,并运行以下命今来编译.ts文件
tsc index.ts
2.4.6、rrvideo 录制弹窗展示不全,展示多个等问题

只要你使用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": "[email protected]",
  "_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": "[email protected]"
  },
  "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上的问题

2.5、rrvideo完整修改后代码

操作步骤

  • 按照2.4.2、2.4.3、2.4.4、2.4.6更改下载的rrvideo源码中的index.ts
  • 按照2.4.5生成对应的index.js
  • 将生成的index.js替换服务器安装的rrvideo对应的index.js

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;

猜你喜欢

转载自blog.csdn.net/qq_43459099/article/details/134997221