mirror of
https://github.com/cypress-io/cypress.git
synced 2026-04-22 23:20:24 -05:00
b5c21205d8
* fix(ff): use fixed inputFPS instead of vfr to prevent dropped frames
266 lines
7.0 KiB
JavaScript
266 lines
7.0 KiB
JavaScript
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 = {
|
|
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 })
|
|
})
|
|
},
|
|
|
|
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 written = false
|
|
let logErrors = true
|
|
let wantsWrite = true
|
|
let skipped = 0
|
|
|
|
_.defaults(options, {
|
|
onError () {},
|
|
})
|
|
|
|
const endVideoCapture = function () {
|
|
done = true
|
|
|
|
if (!written) {
|
|
// when no data has been written this will
|
|
// result in an 'pipe:0: End of file' error
|
|
// for so we need to account for that
|
|
// and not log errors to the console
|
|
logErrors = false
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// we have written at least 1 byte
|
|
written = true
|
|
|
|
debugFrames('writing video frame')
|
|
|
|
if (lengths[data.length]) {
|
|
return
|
|
}
|
|
|
|
lengths[data.length] = true
|
|
|
|
if (wantsWrite) {
|
|
if (!(wantsWrite = pt.write(data))) {
|
|
return pt.once('drain', () => {
|
|
debugFrames('video stream drained')
|
|
|
|
wantsWrite = true
|
|
})
|
|
}
|
|
} else {
|
|
skipped += 1
|
|
|
|
return debugFrames('skipping video frame %o', { skipped })
|
|
}
|
|
}
|
|
|
|
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 })
|
|
|
|
// if we're supposed log errors then
|
|
// bubble them up
|
|
if (logErrors) {
|
|
options.onError(err, stdout, stderr)
|
|
}
|
|
|
|
// reject the ended promise
|
|
return ended.reject(err)
|
|
}).on('end', () => {
|
|
debug('capture ended')
|
|
|
|
return ended.resolve()
|
|
})
|
|
|
|
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')
|
|
|
|
// 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'`)
|
|
|
|
// same as above but scales instead of crops
|
|
// .videoFilters("scale=trunc(iw/2)*2:trunc(ih/2)*2")
|
|
} else {
|
|
cmd
|
|
.inputFormat('image2pipe')
|
|
.inputOptions('-use_wallclock_as_timestamps 1')
|
|
}
|
|
|
|
return cmd.save(name)
|
|
})
|
|
}
|
|
|
|
return startCapturing()
|
|
.then(({ cmd, startedVideoCapture }) => {
|
|
return {
|
|
cmd,
|
|
endVideoCapture,
|
|
writeVideoFrame,
|
|
startedVideoCapture,
|
|
}
|
|
})
|
|
},
|
|
|
|
process (name, cname, videoCompression, onProgress = function () {}) {
|
|
let total = null
|
|
|
|
return new Promise((resolve, reject) => {
|
|
debug('processing video from %s to %s video compression %o',
|
|
name, cname, videoCompression)
|
|
|
|
ffmpeg()
|
|
.input(name)
|
|
.videoCodec('libx264')
|
|
.outputOptions([
|
|
'-preset fast',
|
|
`-crf ${videoCompression}`,
|
|
])
|
|
// .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'")
|
|
.on('start', (command) => {
|
|
return debug('compression started %o', { command })
|
|
}).on('codecData', (data) => {
|
|
debug('compression codec data: %o', data)
|
|
|
|
total = utils.timemarkToSeconds(data.duration)
|
|
}).on('stderr', (stderr) => {
|
|
return 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(() => {
|
|
return resolve()
|
|
})
|
|
}).save(cname)
|
|
})
|
|
},
|
|
|
|
}
|