UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

346 lines (282 loc) 9.5 kB
const _ = require('lodash') const utils = require('fluent-ffmpeg/lib/utils') const debug = require('debug')('cypress:server:video') const ffmpeg = require('fluent-ffmpeg') const stream = require('stream') const Promise = require('bluebird') const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path const BlackHoleStream = require('black-hole-stream') const { fs } = require('./util/fs') // extra verbose logs for logging individual frames const debugFrames = require('debug')('cypress-verbose:server:video:frames') debug('using ffmpeg from %s', ffmpegPath) ffmpeg.setFfmpegPath(ffmpegPath) const deferredPromise = function () { let reject let resolve = (reject = null) const promise = new Promise((_resolve, _reject) => { resolve = _resolve reject = _reject }) return { promise, resolve, reject } } module.exports = { generateFfmpegChaptersConfig (tests) { if (!tests) { return null } const configString = tests.map((test) => { return test.attempts.map((attempt, i) => { const { videoTimestamp, wallClockDuration } = attempt let title = test.title ? test.title.join(' ') : '' if (i > 0) { title += `attempt ${i}` } return [ '[CHAPTER]', 'TIMEBASE=1/1000', `START=${videoTimestamp - wallClockDuration}`, `END=${videoTimestamp}`, `title=${title}`, ].join('\n') }).join('\n') }).join('\n') return `;FFMETADATA1\n${configString}` }, getMsFromDuration (duration) { return utils.timemarkToSeconds(duration) * 1000 }, getCodecData (src) { return new Promise((resolve, reject) => { return ffmpeg() .on('stderr', (stderr) => { return debug('get codecData stderr log %o', { message: stderr }) }).on('codecData', resolve) .input(src) .format('null') .output(new BlackHoleStream()) .run() }).tap((data) => { return debug('codecData %o', { src, data, }) }).tapCatch((err) => { return debug('getting codecData failed', { err }) }) }, getChapters (fileName) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(fileName, ['-show_chapters'], (err, metadata) => { if (err) { return reject(err) } resolve(metadata) }) }) }, copy (src, dest) { debug('copying from %s to %s', src, dest) return fs .copyAsync(src, dest, { overwrite: true }) .catch({ code: 'ENOENT' }, () => {}) }, // dont yell about ENOENT errors start (name, options = {}) { const pt = stream.PassThrough() const ended = deferredPromise() let done = false let wantsWrite = true let skippedChunksCount = 0 let writtenChunksCount = 0 _.defaults(options, { onError () {}, }) const endVideoCapture = function (waitForMoreChunksTimeout = 3000) { debugFrames('frames written:', writtenChunksCount) // in some cases (webm) ffmpeg will crash if fewer than 2 buffers are // written to the stream, so we don't end capture until we get at least 2 if (writtenChunksCount < 2) { return new Promise((resolve) => { pt.once('data', resolve) }) .then(endVideoCapture) .timeout(waitForMoreChunksTimeout) } done = true pt.end() // return the ended promise which will eventually // get resolve or rejected return ended.promise } const lengths = {} const writeVideoFrame = function (data) { // make sure we haven't ended // our stream yet because paint // events can linger beyond // finishing the actual video if (done) { return } // when `data` is empty, it is sent as an empty Buffer (`<Buffer >`) // which can crash the process. this can happen if there are // errors in the video capture process, which are handled later // on, so just skip empty frames here. // @see https://github.com/cypress-io/cypress/pull/6818 if (_.isEmpty(data)) { debugFrames('empty chunk received %o', data) return } if (lengths[data.length]) { // this prevents multiple chunks of webm metadata from being written to the stream // which would crash ffmpeg debugFrames('duplicate length frame received:', data.length) return } writtenChunksCount++ debugFrames('writing video frame') lengths[data.length] = true if (wantsWrite) { if (!(wantsWrite = pt.write(data))) { return pt.once('drain', () => { debugFrames('video stream drained') wantsWrite = true }) } } else { skippedChunksCount += 1 return debugFrames('skipping video frame %o', { skipped: skippedChunksCount }) } } const startCapturing = () => { return new Promise((resolve) => { const cmd = ffmpeg({ source: pt, priority: 20, }) .videoCodec('libx264') .outputOptions('-preset ultrafast') .on('start', (command) => { debug('capture started %o', { command }) return resolve({ cmd, startedVideoCapture: new Date, }) }).on('codecData', (data) => { return debug('capture codec data: %o', data) }).on('stderr', (stderr) => { return debug('capture stderr log %o', { message: stderr }) }).on('error', (err, stdout, stderr) => { debug('capture errored: %o', { error: err.message, stdout, stderr }) // bubble errors up options.onError(err, stdout, stderr) // reject the ended promise return ended.reject(err) }).on('end', () => { debug('capture ended') return ended.resolve() }) // this is to prevent the error "invalid data input" error // when input frames have an odd resolution .videoFilters(`crop='floor(in_w/2)*2:floor(in_h/2)*2'`) if (options.webmInput) { cmd .inputFormat('webm') // assume 18 fps. This number comes from manual measurement of avg fps coming from firefox. // TODO: replace this with the 'vfr' option below when dropped frames issue is fixed. .inputFPS(18) // 'vsync vfr' (variable framerate) works perfectly but fails on top page navigation // since video timestamp resets to 0, timestamps already written will be dropped // .outputOption('-vsync vfr') } else { cmd .inputFormat('image2pipe') .inputOptions('-use_wallclock_as_timestamps 1') } return cmd.save(name) }) } return startCapturing() .then(({ cmd, startedVideoCapture }) => { return { _pt: pt, cmd, endVideoCapture, writeVideoFrame, startedVideoCapture, } }) }, async process (name, cname, videoCompression, ffmpegchaptersConfig, onProgress = function () {}) { const metaFileName = `${name}.meta` const maybeGenerateMetaFile = Promise.method(() => { if (!ffmpegchaptersConfig) { return false } // Writing the metadata to filesystem is necessary because fluent-ffmpeg is just a wrapper of ffmpeg command. return fs.writeFile(metaFileName, ffmpegchaptersConfig).then(() => true) }) const addChaptersMeta = await maybeGenerateMetaFile() let total = null return new Promise((resolve, reject) => { debug('processing video from %s to %s video compression %o', name, cname, videoCompression) const command = ffmpeg() const outputOptions = [ '-preset fast', `-crf ${videoCompression}`, ] if (addChaptersMeta) { command.input(metaFileName) outputOptions.push('-map_metadata 1') } command.input(name) .videoCodec('libx264') .outputOptions(outputOptions) // .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'") .on('start', (command) => { debug('compression started %o', { command }) }) .on('codecData', (data) => { debug('compression codec data: %o', data) total = utils.timemarkToSeconds(data.duration) }) .on('stderr', (stderr) => { debug('compression stderr log %o', { message: stderr }) }) .on('progress', (progress) => { // bail if we dont have total yet if (!total) { return } debug('compression progress: %o', progress) const progressed = utils.timemarkToSeconds(progress.timemark) const percent = progressed / total if (percent < 1) { return onProgress(percent) } }) .on('error', (err, stdout, stderr) => { debug('compression errored: %o', { error: err.message, stdout, stderr }) return reject(err) }) .on('end', () => { debug('compression ended') // we are done progressing onProgress(1) // rename and obliterate the original return fs.moveAsync(cname, name, { overwrite: true, }) .then(() => { if (addChaptersMeta) { return fs.unlink(metaFileName) } }) .then(() => { return resolve() }) }).save(cname) }) }, }