* 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>
218 lines
5.4 KiB
TypeScript
218 lines
5.4 KiB
TypeScript
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();
|
|
});
|
|
}
|