UNPKG

browsertime

Version:

Get performance metrics from your web page using Browsertime.

209 lines (186 loc) 6.35 kB
import { promisify } from 'node:util'; import { unlink as _unlink, readdirSync, statSync, createWriteStream, copyFileSync } from 'node:fs'; import path from 'node:path'; import intel from 'intel'; import { execa } from 'execa'; import { removeDirAndFiles } from '../../../support/fileUtil.js'; import { Android, isAndroidConfigured } from '../../../android/index.js'; const log = intel.getLogger('browsertime.video'); const unlink = promisify(_unlink); async function enableWindowRecorder(enable, browser) { enable = enable ? 'true' : 'false'; // We wrap windowUtils.setCompositionRecording(...) in Promise.resolve() to // work on both the older (synchronous) and newer (asynchronous) // setCompositionRecorder API. const script = `\ const cb = arguments[arguments.length-1]; Promise.resolve( windowUtils.setCompositionRecording(${enable}) ) .then(() => cb()); `; return browser.runPrivilegedAsyncScript( script, 'toggle composition recorder' ); } function findRecordingDirectory(baseDir) { let closest_mtime = 0; let directory; for (const file of readdirSync(baseDir)) { if (file.startsWith('windowrecording-')) { let fullPath = path.join(baseDir, file); let mtime = statSync(fullPath).mtime; if (mtime > closest_mtime) { closest_mtime = mtime; directory = fullPath; } } } log.debug('Using window recording directory: ' + directory); if (directory === undefined) { log.error('Could not find window recording directory in ' + baseDir); } return directory; } function findRecordingStartTime(recordingDir) { // Recording directory has the following format: // .../browsertime-results/<web address>/<date time>/windowrecording-<unix recording start time> return recordingDir.slice(recordingDir.lastIndexOf('-') + 1); } function findTimeToFirstFrame(recordingDir) { const files = readdirSync(recordingDir); if (files === undefined || files.length === 0) { log.error('Could not find recordings in ' + recordingDir); } else { // Iterate through files, extract the time from the filenames, and return the smallest time // eslint-disable-next-line unicorn/no-array-reduce return files.reduce((accumulator, fileName) => { const time = Number.parseInt(fileName.split('-')[2].split('.')[0], 10); return Math.min(accumulator, time); }, Number.POSITIVE_INFINITY); } } function writeFrameDurationsToFile(directoryName, imageFiles) { return new Promise((resolve, reject) => { const stream = createWriteStream(path.join(directoryName, 'durations.txt')); stream.once('open', function () { stream.write( "file '" + path.join(directoryName, imageFiles[0].filename) + "'\n" ); for (let index = 1; index < imageFiles.length; index++) { let duration = (imageFiles[index].offset - imageFiles[index - 1].offset) / 1000; stream.write('duration ' + duration.toString() + '\n'); stream.write( "file '" + path.join(directoryName, imageFiles[index].filename) + "'\n" ); } stream.end(); stream.on('finish', () => { resolve(true); }); stream.on('error', () => { reject(new Error('Error writing durations to file.')); }); }); }); } async function generateVideo(destination, recordingDirectoryName) { let imageFiles = []; for (const file of readdirSync(recordingDirectoryName)) { // Format of the filenames are "frame-<num>-<offset>.png" // where num is frame number and offset is time since capture start in ms. let fields = file.split('-'); let frameno = ('0000' + fields[1]).slice(-4); let newFilename = 'frame' + frameno + '.png'; let offset = fields[2].split('.')[0]; copyFileSync( path.join(recordingDirectoryName, file), path.join(recordingDirectoryName, newFilename) ); imageFiles.push({ filename: newFilename, offset: offset }); } imageFiles.sort(function (a, b) { if (a.filename < b.filename) return -1; if (a.filename > b.filename) return 1; return 0; }); await writeFrameDurationsToFile(recordingDirectoryName, imageFiles); const vfr_arguments = [ '-f', 'concat', '-safe', '0', '-i', path.join(recordingDirectoryName, 'durations.txt'), '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-vsync', 'vfr', '-pix_fmt', 'yuv420p', destination ]; log.debug('Executing command: ffmpeg ' + vfr_arguments.join(' ')); await execa('ffmpeg', vfr_arguments); return removeDirAndFiles(recordingDirectoryName); } export class FirefoxWindowRecorder { constructor(options, browser, baseDir) { this.options = options; this.browser = browser; this.baseDir = baseDir; this.recordingStartTime = undefined; this.timeToFirstFrame = undefined; } async start() { log.info('Start firefox window recorder.'); if (isAndroidConfigured(this.options)) { this.android = new Android(this.options); await this.android.removePathOnSdCard( 'browsertime-firefox-windowrecording' ); await this.android.mkDirOnSdCard('browsertime-firefox-windowrecording'); } return enableWindowRecorder(true, this.browser); } async stop(destination) { log.info('Stop firefox window recorder.'); await enableWindowRecorder(false, this.browser); if (isAndroidConfigured(this.options)) { const fullPathOnSdCard = this.android.getFullPathOnSdCard( 'browsertime-firefox-windowrecording' ); await this.android._downloadDir(fullPathOnSdCard, this.baseDir); } // FIXME update to rename/move file // The destination file could exixt of we use --resultDir // so make sure we remove it first if (this.options.resultDir) { try { await unlink(destination); } catch { // Nothing to see here } } let recordingDir = findRecordingDirectory(this.baseDir); this.recordingStartTime = findRecordingStartTime(recordingDir); this.timeToFirstFrame = findTimeToFirstFrame(recordingDir); await generateVideo( destination, recordingDir, this.recordingStartTime, this.timeToFirstFrame ); log.debug(`Writing to ${destination}`); } }