move rrvideo to monorepo (#1181)

* first commit

* rrvideo v0.1.0

First version of rrvideo.

1. Use as a Node.JS lib.
2. Use as a CLI.

Features are implemented via puppeteer, ffmpeg and rrweb-player.

* add readme

* update publish script

* add node env in cli file and change package.json bin to same like README (#4)

Co-authored-by: Xu Yinjie <xuyinjie@xiaobangtouzi.com>

* release 0.2.0

* fix #6 avoid assign undefined to config

* Fix: Solve the inconsistency between rrvideo and the real recorded page rendering when rendering the page with a headless browser (https://github.com/rrweb-io/rrvideo/pull/26)

Author: xujiujiu <906784584@qq.com>

---------

Co-authored-by: xujiujiu <906784584@qq.com>

* refactor rrvideo

1. refactor code
2. change monorepo config
3. remove separate TS dependencies

* add changeset

* fix: eslint errors

---------

Co-authored-by: Yanzhen Yu <yanzhen@smartx.com>
Co-authored-by: xyj <593500664@qq.com>
Co-authored-by: Xu Yinjie <xuyinjie@xiaobangtouzi.com>
Co-authored-by: xujiujiu <906784584@qq.com>
This commit is contained in:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 031a72721c
commit 78935ea820
25 changed files with 585 additions and 2521 deletions

View File

@@ -10,7 +10,8 @@
"rrdom-nodejs", "rrdom-nodejs",
"rrweb-player", "rrweb-player",
"@rrweb/types", "@rrweb/types",
"@rrweb/web-extension" "@rrweb/web-extension",
"rrvideo"
] ]
], ],
"linked": [], "linked": [],

View File

@@ -0,0 +1,5 @@
---
'rrvideo': patch
---
Refactor: Move rrvideo to rrweb's monorepo

View File

@@ -19,6 +19,8 @@ body:
- rrweb-snapshot - rrweb-snapshot
- rrdom - rrdom
- rrweb-player - rrweb-player
- web-extension
- rrvideo
- Other (specify below) - Other (specify below)
validations: validations:
required: true required: true

View File

@@ -19,6 +19,8 @@ body:
- rrweb-snapshot - rrweb-snapshot
- rrdom - rrdom
- rrweb-player - rrweb-player
- web-extension
- rrvideo
- Other (specify below) - Other (specify below)
validations: validations:
required: true required: true

View File

@@ -28,6 +28,7 @@
"name": "web-extension (package)", "name": "web-extension (package)",
"path": "../packages/web-extension" "path": "../packages/web-extension"
}, },
{ "name": "rrvideo (package)", "path": "../packages/rrvideo" },
{ "name": "@rrweb/types", "path": "../packages/types" } { "name": "@rrweb/types", "path": "../packages/types" }
], ],
"settings": { "settings": {

View File

@@ -29,7 +29,6 @@
"eslint-plugin-compat": "^4.0.2", "eslint-plugin-compat": "^4.0.2",
"eslint-plugin-jest": "^27.1.3", "eslint-plugin-jest": "^27.1.3",
"eslint-plugin-tsdoc": "^0.2.16", "eslint-plugin-tsdoc": "^0.2.16",
"lerna": "^4.0.0",
"markdownlint": "^0.25.1", "markdownlint": "^0.25.1",
"markdownlint-cli": "^0.31.1", "markdownlint-cli": "^0.31.1",
"prettier": "2.8.4", "prettier": "2.8.4",
@@ -37,7 +36,6 @@
"typescript": "^4.7.3" "typescript": "^4.7.3"
}, },
"scripts": { "scripts": {
"lerna": "lerna",
"build:all": "yarn run concurrently --success=all -r -m=1 'yarn workspaces-to-typescript-project-references' 'yarn turbo run prepublish'", "build:all": "yarn run concurrently --success=all -r -m=1 'yarn workspaces-to-typescript-project-references' 'yarn turbo run prepublish'",
"test": "yarn run concurrently --success=all -r -m=1 'yarn workspaces-to-typescript-project-references --check' 'yarn turbo run test'", "test": "yarn run concurrently --success=all -r -m=1 'yarn workspaces-to-typescript-project-references --check' 'yarn turbo run test'",
"test:watch": "yarn turbo run test:watch", "test:watch": "yarn turbo run test:watch",

View File

@@ -42,8 +42,7 @@
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.31.2", "rollup-plugin-typescript2": "^0.31.2",
"rollup-plugin-web-worker-loader": "^1.6.1", "rollup-plugin-web-worker-loader": "^1.6.1",
"ts-jest": "^27.1.3", "ts-jest": "^27.1.3"
"typescript": "^4.7.3"
}, },
"dependencies": { "dependencies": {
"cssom": "^0.5.0", "cssom": "^0.5.0",

View File

@@ -44,8 +44,7 @@
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.31.2", "rollup-plugin-typescript2": "^0.31.2",
"rollup-plugin-web-worker-loader": "^1.6.1", "rollup-plugin-web-worker-loader": "^1.6.1",
"ts-jest": "^27.1.3", "ts-jest": "^27.1.3"
"typescript": "^4.7.3"
}, },
"dependencies": { "dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.6" "rrweb-snapshot": "^2.0.0-alpha.6"

View File

@@ -0,0 +1,37 @@
# rrvideo
[中文文档](./README.zh_CN.md)
rrvideo is a tool for transforming the session recorded by [rrweb](https://github.com/rrweb-io/rrweb) into a video.
## Install rrvideo
1. Install [ffmpeg](https://ffmpeg.org/download.html)。
2. Install [Node.JS](https://nodejs.org/en/download/)。
3. Run `npm i -g rrvideo` to install the rrvideo CLI。
## Use rrvideo
### Transform a rrweb session(in JSON format) into a video.
```shell
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE
```
Running this command will output a `rrvideo-output.mp4` file in the current working directory.
### Config the output path
```shell
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE --output OUTPUT_PATH
```
### Config the replay
You can prepare a rrvideo config file and pass it to CLI.
```shell
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_JSON_FILE --config PATH_TO_YOUR_RRVIDEO_CONFIG_FILE
```
You can find an example of the rrvideo config file [here](./rrvideo.config.example.json).

View File

@@ -0,0 +1,35 @@
# rrvideo
rrvideo 是用于将 [rrweb](https://github.com/rrweb-io/rrweb) 录制的数据转为视频格式的工具。
## 安装 rrvideo
1. 安装 [ffmpeg](https://ffmpeg.org/download.html)。
2. 安装 [Node.JS](https://nodejs.org/en/download/)。
3. 执行 `npm i -g rrvideo` 以安装 rrvideo CLI。
## 使用 rrvideo
### 将一份 rrweb 录制的数据JSON 格式)转换为视频。
```shell
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE
```
运行以上命令会在执行文件夹中生成一个 `rrvideo-output.mp4` 文件。
### 指定输出路径
```shell
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE --output OUTPUT_PATH
```
### 对回放进行配置
通过编写一个 rrvideo 配置文件再传入 rrvideo CLI 的方式可以对回放进行一定的配置。
```shell
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_JSON_FILE --config PATH_TO_YOUR_RRVIDEO_CONFIG_FILE
```
rrvideo 配置文件可参考[示例](./rrvideo.config.example.json)。

View File

@@ -0,0 +1,28 @@
{
"name": "rrvideo",
"version": "2.0.0-alpha.6",
"description": "transform rrweb session into video",
"main": "build/index.js",
"bin": {
"rrvideo": "build/cli.js"
},
"files": [
"build"
],
"types": "build/index.d.ts",
"scripts": {
"build": "tsc",
"prepublish": "yarn build"
},
"author": "yanzhen@smartx.com",
"license": "MIT",
"devDependencies": {
"@types/minimist": "^1.2.1",
"@rrweb/types": "^2.0.0-alpha.6"
},
"dependencies": {
"minimist": "^1.2.5",
"puppeteer": "^19.7.2",
"rrweb-player": "^2.0.0-alpha.6"
}
}

View File

@@ -0,0 +1,10 @@
{
"width": 1400,
"height": 900,
"speed": 4,
"skipInactive": true,
"mouseTail": {
"strokeStyle": "green",
"lineWidth": 2
}
}

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env node
import * as fs from 'fs';
import * as path from 'path';
import minimist from 'minimist';
import type { RRwebPlayerOptions } from 'rrweb-player';
import { transformToVideo } from './index';
const argv = minimist(process.argv.slice(2));
if (!argv.input) {
throw new Error('please pass --input to your rrweb events file');
}
let config = {};
if (argv.config) {
const configPathStr = argv.config as string;
const configPath = path.isAbsolute(configPathStr)
? configPathStr
: path.resolve(process.cwd(), configPathStr);
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Omit<
RRwebPlayerOptions['props'],
'events'
>;
}
transformToVideo({
input: argv.input as string,
output: argv.output as string,
rrwebPlayer: config,
})
.then((file) => {
console.log(`Successfully transformed into "${file}".`);
})
.catch((error) => {
console.log('Failed to transform this session.');
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,217 @@
import * as fs from 'fs';
import * as path from 'path';
import { spawn } from 'child_process';
import puppeteer from 'puppeteer';
import type { Page, Browser } from 'puppeteer';
import type { eventWithTime } from '@rrweb/types';
import type { RRwebPlayerOptions } from 'rrweb-player';
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');
type RRvideoConfig = {
input: string;
output?: string;
headless?: boolean;
fps?: number;
cb?: (file: string, error: null | Error) => void;
// start playback delay time
startDelayTime?: number;
rrwebPlayer?: Omit<RRwebPlayerOptions['props'], 'events'>;
};
const defaultConfig: Required<RRvideoConfig> = {
input: '',
output: 'rrvideo-output.mp4',
headless: true,
fps: 15,
cb: () => {
//
},
startDelayTime: 1000,
rrwebPlayer: {},
};
function getHtml(
events: Array<eventWithTime>,
config?: Omit<RRwebPlayerOptions['props'], 'events'>,
): string {
return `
<html>
<head>
<style>${rrwebStyle}</style>
</head>
<body>
<script>
${rrwebRaw};
/*<!--*/
const events = ${JSON.stringify(events).replace(
/<\/script>/g,
'<\\/script>',
)};
/*-->*/
const userConfig = ${JSON.stringify(config || {})};
window.replayer = new rrwebPlayer({
target: document.body,
props: {
...userConfig,
events,
showController: false,
autoPlay: false, // autoPlay off by default
},
});
window.replayer.addEventListener('finish', () => window.onReplayFinish());
</script>
</body>
</html>
`;
}
export class RRvideo {
private browser!: Browser;
private page!: Page;
private state: 'idle' | 'recording' | 'closed' = 'idle';
private config = {
...defaultConfig,
};
constructor(config: RRvideoConfig) {
this.updateConfig(config);
}
public async transform() {
try {
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('onReplayFinish', () => {
void 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'),
) as eventWithTime[];
await this.page.setContent(getHtml(events, this.config.rrwebPlayer));
setTimeout(() => {
void this.startRecording().then(() => {
return this.page.evaluate('window.replayer.play();');
});
}, this.config.startDelayTime);
} catch (error) {
this.config.cb('', error as Error);
}
}
public updateConfig(config: RRvideoConfig) {
if (!config.input) throw new Error('input is required');
config.output = config.output || defaultConfig.output;
Object.assign(this.config, defaultConfig, config);
}
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(() => {
if (this.state === 'recording' && !processError) {
void wrapperEl
.screenshot({
encoding: 'binary',
})
.then((buffer) => ffmpegProcess.stdin.write(buffer))
.catch();
} 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: RRvideoConfig): Promise<string> {
return new Promise((resolve, reject) => {
const rrvideo = new RRvideo({
...config,
cb(file, error) {
if (error) {
return reject(error);
}
resolve(file);
},
});
void rrvideo.transform();
});
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"composite": true,
"target": "ES6",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"outDir": "./build",
"rootDir": "./src",
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"references": [
{
"path": "../rrweb-player"
},
{
"path": "../types"
}
]
}

View File

@@ -20,8 +20,7 @@
"svelte": "^3.2.0", "svelte": "^3.2.0",
"svelte-check": "^1.4.0", "svelte-check": "^1.4.0",
"svelte-preprocess": "^4.0.0", "svelte-preprocess": "^4.0.0",
"tslib": "^2.0.0", "tslib": "^2.0.0"
"typescript": "^4.7.3"
}, },
"dependencies": { "dependencies": {
"@tsconfig/svelte": "^1.0.0", "@tsconfig/svelte": "^1.0.0",

View File

@@ -220,7 +220,7 @@
export const playRange = ( export const playRange = (
timeOffset: number, timeOffset: number,
endTimeOffset: number, endTimeOffset: number,
startLooping: boolean = false, startLooping = false,
afterHook: undefined | (() => void) = undefined, afterHook: undefined | (() => void) = undefined,
) => { ) => {
if (startLooping) { if (startLooping) {

View File

@@ -121,7 +121,7 @@
export const playRange = ( export const playRange = (
timeOffset: number, timeOffset: number,
endTimeOffset: number, endTimeOffset: number,
startLooping: boolean = false, startLooping = false,
afterHook: undefined | (() => void) = undefined, afterHook: undefined | (() => void) = undefined,
) => { ) => {
controller.playRange(timeOffset, endTimeOffset, startLooping, afterHook); controller.playRange(timeOffset, endTimeOffset, startLooping, afterHook);

View File

@@ -56,7 +56,6 @@
"rollup-plugin-typescript2": "^0.31.2", "rollup-plugin-typescript2": "^0.31.2",
"ts-jest": "^27.0.5", "ts-jest": "^27.0.5",
"ts-node": "^7.0.1", "ts-node": "^7.0.1",
"tslib": "^1.9.3", "tslib": "^1.9.3"
"typescript": "^4.7.3"
} }
} }

View File

@@ -74,8 +74,7 @@
"simple-peer-light": "^9.10.0", "simple-peer-light": "^9.10.0",
"ts-jest": "^27.1.3", "ts-jest": "^27.1.3",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tslib": "^2.3.1", "tslib": "^2.3.1"
"typescript": "^4.7.3"
}, },
"dependencies": { "dependencies": {
"@rrweb/types": "^2.0.0-alpha.6", "@rrweb/types": "^2.0.0-alpha.6",

View File

@@ -39,7 +39,6 @@
"dist" "dist"
], ],
"devDependencies": { "devDependencies": {
"typescript": "^4.7.3",
"vite": "^3.2.0-beta.2", "vite": "^3.2.0-beta.2",
"vite-plugin-dts": "^1.6.6" "vite-plugin-dts": "^1.6.6"
}, },

View File

@@ -22,7 +22,6 @@
"@vitejs/plugin-react": "^2.1.0", "@vitejs/plugin-react": "^2.1.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"type-fest": "^2.19.0", "type-fest": "^2.19.0",
"typescript": "^4.7.3",
"vite": "^3.1.8", "vite": "^3.1.8",
"vite-plugin-web-extension": "^1.4.5", "vite-plugin-web-extension": "^1.4.5",
"vite-plugin-zip-pack": "^1.0.5", "vite-plugin-zip-pack": "^1.0.5",

View File

@@ -1,15 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true,
"baseUrl": ".", "baseUrl": ".",
"module": "ESNext", "module": "ESNext",
"target": "es2016", "target": "es2016",
"lib": [ "lib": ["DOM", "ESNext"],
"DOM",
"ESNext"
],
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"incremental": false, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
@@ -17,16 +15,11 @@
"strictNullChecks": true, "strictNullChecks": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"paths": { "paths": {
"~/*": [ "~/*": ["src/*"]
"src/*"
]
}, },
"jsx": "react-jsx" "jsx": "react-jsx"
}, },
"exclude": [ "exclude": ["dist", "node_modules"],
"dist",
"node_modules"
],
"references": [ "references": [
{ {
"path": "../rrweb" "path": "../rrweb"

View File

@@ -17,6 +17,12 @@
}, },
{ {
"path": "packages/types" "path": "packages/types"
},
{
"path": "packages/rrvideo"
},
{
"path": "packages/web-extension"
} }
], ],
"files": [], "files": [],

2654
yarn.lock

File diff suppressed because it is too large Load Diff