import * as fs from 'fs-extra'; import * as path from 'path'; import { chromium } from 'playwright'; import { EventType, eventWithTime } from '@rrweb/types'; import type Player from 'rrweb-player'; const rrwebScriptPath = path.resolve( require.resolve('rrweb-player'), '../../dist/rrweb-player.umd.cjs', ); 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< ConstructorParameters[0]['props'], 'events' >; }; const defaultConfig: Required = { 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, config?: RRvideoConfig): string { return ` `; } /** * 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'); // Listen to console messages from the page page.on('console', (msg) => { console.log('[PAGE CONSOLE]', msg.type(), msg.text()); }); // Listen to page errors page.on('pageerror', (error) => { console.error('[PAGE ERROR]', error.message); }); await page.exposeFunction( 'onReplayProgressUpdate', (data: { payload: number }) => { config.onProgressUpdate(data.payload); }, ); // Wait for the replay to finish await new Promise((resolve, reject) => { const timeoutBuffer = 120000; // 2 minute timeout buffer const videoStartTime = events[0]?.timestamp; const videoEndTime = events[events.length - 1]?.timestamp; const videoDuration = videoEndTime - videoStartTime; const videoPlaybackSpeed = options.rrwebPlayer?.speed || 1; const expectedPlaybackTime = videoDuration / videoPlaybackSpeed; console.log( `[DEBUG] Expected playback time: ${expectedPlaybackTime}ms (video duration: ${videoDuration}ms, playback speed: ${videoPlaybackSpeed}x)`, ); const totalTimeout = expectedPlaybackTime + timeoutBuffer; const timeout = setTimeout(() => { console.error('[DEBUG] Replay timeout - finish event never fired'); reject(new Error('Replay timeout')); }, totalTimeout); // playback + 2 minute timeout void page .exposeFunction('onReplayFinish', () => { console.log('[DEBUG] Replay finished'); clearTimeout(timeout); resolve(); }) .then(() => { console.log('[DEBUG] Setting page content'); return page.setContent(getHtml(events, config)); }) .then(() => { console.log('[DEBUG] Page content set successfully'); }) .catch((err) => { console.error('[DEBUG] Error setting page content:', err); clearTimeout(timeout); reject(err); }); }); 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; }