Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
refactor rrvideo: use playwright rather than puppeteer
  • Loading branch information
YunFeng0817 committed Mar 31, 2023
commit a064f53f49bbf086fc452246549deaf14ea44b1d
7 changes: 3 additions & 4 deletions packages/rrvideo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ rrvideo is a tool for transforming the session recorded by [rrweb](https://githu

## 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。
1. Install [Node.JS](https://nodejs.org/en/download/)。
2. Run `npm i -g rrvideo` to install the rrvideo CLI.

## Use rrvideo

Expand All @@ -18,7 +17,7 @@ rrvideo is a tool for transforming the session recorded by [rrweb](https://githu
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE
```

Running this command will output a `rrvideo-output.mp4` file in the current working directory.
Running this command will output a `rrvideo-output.webm` file in the current working directory.

### Config the output path

Expand Down
7 changes: 3 additions & 4 deletions packages/rrvideo/README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ 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。
1. 安装 [Node.JS](https://nodejs.org/en/download/)。
2. 执行 `npm i -g rrvideo` 以安装 rrvideo CLI。

## 使用 rrvideo

Expand All @@ -16,7 +15,7 @@ rrvideo 是用于将 [rrweb](https://github.com/rrweb-io/rrweb) 录制的数据
rrvideo --input PATH_TO_YOUR_RRWEB_EVENTS_FILE
```

运行以上命令会在执行文件夹中生成一个 `rrvideo-output.mp4` 文件。
运行以上命令会在执行文件夹中生成一个 `rrvideo-output.webm` 文件。

### 指定输出路径

Expand Down
5 changes: 4 additions & 1 deletion packages/rrvideo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@
],
"types": "build/index.d.ts",
"scripts": {
"install": "playwright install",
"build": "tsc",
"prepublish": "yarn build"
},
"author": "[email protected]",
"license": "MIT",
"devDependencies": {
"@types/fs-extra": "11.0.1",
"@types/minimist": "^1.2.1",
"@rrweb/types": "^2.0.0-alpha.7"
},
"dependencies": {
"fs-extra": "^11.1.1",
"minimist": "^1.2.5",
"puppeteer": "^19.7.2",
"playwright": "^1.32.1",
"rrweb-player": "^2.0.0-alpha.7"
}
}
232 changes: 78 additions & 154 deletions packages/rrvideo/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import * as fs from 'fs';
import * as fs from 'fs-extra';
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 { chromium } from 'playwright';
import { EventType, eventWithTime } from '@rrweb/types';
import type { RRwebPlayerOptions } from 'rrweb-player';

const rrwebScriptPath = path.resolve(
Expand All @@ -18,22 +16,13 @@ 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',
output: 'rrvideo-output.webm',
headless: true,
fps: 15,
cb: () => {
//
},
startDelayTime: 1000,
rrwebPlayer: {},
};

Expand All @@ -45,6 +34,7 @@ function getHtml(
<html>
<head>
<style>${rrwebStyle}</style>
<style>html, body {padding: 0; border: none; margin: 0;}</style>
</head>
<body>
<script>
Expand All @@ -62,7 +52,6 @@ function getHtml(
...userConfig,
events,
showController: false,
autoPlay: false, // autoPlay off by default
},
});
window.replayer.addEventListener('finish', () => window.onReplayFinish());
Expand All @@ -72,146 +61,81 @@ function getHtml(
`;
}

export class RRvideo {
private browser!: Browser;
private page!: Page;
private state: 'idle' | 'recording' | 'closed' = 'idle';
private config = {
...defaultConfig,
/**
* Preprocess all events to get a maximum view port size.
*/
function getMaxViewport(events: eventWithTime[]) {
let maxWidth = 0,
maxHeight = 0;
events.forEach((event) => {
if (event.type !== EventType.Meta) return;
if (event.data.width > maxWidth) maxWidth = event.data.width;
if (event.data.height > maxHeight) maxHeight = event.data.height;
});
return {
width: maxWidth,
height: maxHeight,
};

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();
export async function transformToVideo(options: RRvideoConfig) {
const defaultVideoDir = '__rrvideo__temp__';
const config = { ...defaultConfig };
if (!options.input) throw new Error('input is required');
// If the output is not specified or undefined, use the default value.
if (!options.output) delete options.output;
Object.assign(config, options);
const eventsPath = path.isAbsolute(config.input)
? config.input
: path.resolve(process.cwd(), config.input);
const outputPath = path.isAbsolute(config.output)
? config.output
: path.resolve(process.cwd(), config.output);
const events = JSON.parse(
fs.readFileSync(eventsPath, 'utf-8'),
) as eventWithTime[];

// Make the browser viewport fit the player size.
const maxViewport = getMaxViewport(events);
Object.assign(config.rrwebPlayer, maxViewport);
const browser = await chromium.launch({
headless: config.headless,
});
const context = await browser.newContext({
viewport: maxViewport,
recordVideo: {
dir: defaultVideoDir,
size: maxViewport,
},
});
const page = await context.newPage();
await page.goto('about:blank');
// Wait for the replay to finish
await new Promise<void>(
(resolve) =>
void page
.exposeFunction('onReplayFinish', () => resolve())
.then(() => page.setContent(getHtml(events, config.rrwebPlayer))),
);
const videoPath = (await page.video()?.path()) || '';
const cleanFiles = async (videoPath: string) => {
await fs.remove(videoPath);
if ((await fs.readdir(defaultVideoDir)).length === 0) {
await fs.remove(defaultVideoDir);
}
};
await context.close();
await Promise.all([
fs
.move(videoPath, outputPath, { overwrite: true })
.catch((e) => {
console.error(
"Can't create video file. Please check the output path.",
e,
);
})
.finally(() => void cleanFiles(videoPath)),
browser.close(),
]);
return outputPath;
}
Loading