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:
37
packages/rrvideo/README.md
Normal file
37
packages/rrvideo/README.md
Normal 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).
|
||||
35
packages/rrvideo/README.zh_CN.md
Normal file
35
packages/rrvideo/README.zh_CN.md
Normal 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)。
|
||||
28
packages/rrvideo/package.json
Normal file
28
packages/rrvideo/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
packages/rrvideo/rrvideo.config.example.json
Normal file
10
packages/rrvideo/rrvideo.config.example.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"speed": 4,
|
||||
"skipInactive": true,
|
||||
"mouseTail": {
|
||||
"strokeStyle": "green",
|
||||
"lineWidth": 2
|
||||
}
|
||||
}
|
||||
39
packages/rrvideo/src/cli.ts
Normal file
39
packages/rrvideo/src/cli.ts
Normal 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);
|
||||
});
|
||||
217
packages/rrvideo/src/index.ts
Normal file
217
packages/rrvideo/src/index.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
27
packages/rrvideo/tsconfig.json
Normal file
27
packages/rrvideo/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user