Files
rrweb/packages/rrvideo/src/index.ts
Yun Feng 23d01387f4 rrvideo: improve the video quality and add a progress bar for the CLI tool (#1197)
* refactor rrvideo: use playwright rather than puppeteer

* add a progress bar for the tool

* add tests for cli.ts

* fix build error

* add change log

* update readme file

* Apply a scaling method to improve the resolution of the output video
2023-05-01 14:04:38 +02:00

175 lines
5.3 KiB
TypeScript

import * as fs from 'fs-extra';
import * as path from 'path';
import { chromium } from 'playwright';
import { EventType, 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');
// The max valid scale value for the scaling method which can improve the video quality.
const MaxScaleValue = 2.5;
type RRvideoConfig = {
input: string;
output?: string;
headless?: boolean;
// A number between 0 and 1. The higher the value, the better the quality of the video.
resolutionRatio?: number;
// A callback function that will be called when the progress of the replay is updated.
onProgressUpdate?: (percent: number) => void;
rrwebPlayer?: Omit<RRwebPlayerOptions['props'], 'events'>;
};
const defaultConfig: Required<RRvideoConfig> = {
input: '',
output: 'rrvideo-output.webm',
headless: true,
// A good trade-off value between quality and file size.
resolutionRatio: 0.8,
onProgressUpdate: () => {
//
},
rrwebPlayer: {},
};
function getHtml(events: Array<eventWithTime>, config?: RRvideoConfig): string {
return `
<html>
<head>
<style>${rrwebStyle}</style>
<style>html, body {padding: 0; border: none; margin: 0;}</style>
</head>
<body>
<script>
${rrwebRaw};
/*<!--*/
const events = ${JSON.stringify(events).replace(
/<\/script>/g,
'<\\/script>',
)};
/*-->*/
const userConfig = ${JSON.stringify(config?.rrwebPlayer || {})};
window.replayer = new rrwebPlayer({
target: document.body,
width: userConfig.width,
height: userConfig.height,
props: {
...userConfig,
events,
showController: false,
},
});
window.replayer.addEventListener('finish', () => window.onReplayFinish());
window.replayer.addEventListener('ui-update-progress', (payload)=> window.onReplayProgressUpdate
(payload));
window.replayer.addEventListener('resize',()=>document.querySelector('.replayer-wrapper').style.transform = 'scale(${
(config?.resolutionRatio ?? 1) * MaxScaleValue
}) translate(-50%, -50%)');
</script>
</body>
</html>
`;
}
/**
* 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,
};
}
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);
if (config.resolutionRatio > 1) config.resolutionRatio = 1; // The max value is 1.
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);
// Use the scaling method to improve the video quality.
const scaledViewport = {
width: Math.round(
maxViewport.width * (config.resolutionRatio ?? 1) * MaxScaleValue,
),
height: Math.round(
maxViewport.height * (config.resolutionRatio ?? 1) * MaxScaleValue,
),
};
Object.assign(config.rrwebPlayer, scaledViewport);
const browser = await chromium.launch({
headless: config.headless,
});
const context = await browser.newContext({
viewport: scaledViewport,
recordVideo: {
dir: defaultVideoDir,
size: scaledViewport,
},
});
const page = await context.newPage();
await page.goto('about:blank');
await page.exposeFunction(
'onReplayProgressUpdate',
(data: { payload: number }) => {
config.onProgressUpdate(data.payload);
},
);
// Wait for the replay to finish
await new Promise<void>(
(resolve) =>
void page
.exposeFunction('onReplayFinish', () => resolve())
.then(() => page.setContent(getHtml(events, config))),
);
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;
}