From 1c6925f952276cb0ecb807d730da352f8656232c Mon Sep 17 00:00:00 2001 From: Kukhyeon Heo Date: Sun, 11 Oct 2020 11:32:12 +0900 Subject: [PATCH] feat: Add a video chapter key for each test. (#8755) * Add chapters to the video. * Add test. * Use async, await. * Add more asserts. * Install ffprobe if necessary. * Update ffprobePath. Co-authored-by: Jennifer Shehane --- packages/server/lib/modes/run.js | 7 +- packages/server/lib/video_capture.js | 95 ++++++++++++++++--- packages/server/package.json | 1 + .../test/e2e/6_video_compression_spec.js | 29 +++++- yarn.lock | 48 ++++++++++ 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/packages/server/lib/modes/run.js b/packages/server/lib/modes/run.js index 6ecdee4ff4..6a583d66dc 100644 --- a/packages/server/lib/modes/run.js +++ b/packages/server/lib/modes/run.js @@ -814,7 +814,7 @@ module.exports = { console.log('') }, - async postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet) { + async postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet, ffmpegChaptersConfig) { debug('ending the video recording %o', { name, videoCompression, shouldUploadVideo }) // once this ended promises resolves @@ -826,7 +826,7 @@ module.exports = { } function continueProcessing (onProgress = undefined) { - return videoCapture.process(name, cname, videoCompression, onProgress) + return videoCapture.process(name, cname, videoCompression, ffmpegChaptersConfig, onProgress) } if (quiet) { @@ -1153,12 +1153,15 @@ module.exports = { await openProject.closeBrowser() if (endVideoCapture && !videoCaptureFailed) { + const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(obj.tests) + await this.postProcessRecording( videoName, compressedVideoName, videoCompression, suv, quiet, + ffmpegChaptersConfig, ) .catch(warnVideoRecordingFailed) } diff --git a/packages/server/lib/video_capture.js b/packages/server/lib/video_capture.js index 20f8dc241d..14f1f41971 100644 --- a/packages/server/lib/video_capture.js +++ b/packages/server/lib/video_capture.js @@ -27,6 +27,33 @@ const deferredPromise = function () { } 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 }, @@ -51,6 +78,18 @@ module.exports = { }) }, + 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) @@ -217,30 +256,53 @@ module.exports = { }) }, - process (name, cname, videoCompression, onProgress = function () {}) { + 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) - ffmpeg() - .input(name) - .videoCodec('libx264') - .outputOptions([ + 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) => { - return debug('compression started %o', { command }) - }).on('codecData', (data) => { + 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) => { + }) + .on('stderr', (stderr) => { + debug('compression stderr log %o', { message: stderr }) + }) + .on('progress', (progress) => { // bail if we dont have total yet if (!total) { return @@ -255,11 +317,13 @@ module.exports = { if (percent < 1) { return onProgress(percent) } - }).on('error', (err, stdout, stderr) => { + }) + .on('error', (err, stdout, stderr) => { debug('compression errored: %o', { error: err.message, stdout, stderr }) return reject(err) - }).on('end', () => { + }) + .on('end', () => { debug('compression ended') // we are done progressing @@ -269,6 +333,11 @@ module.exports = { return fs.moveAsync(cname, name, { overwrite: true, }) + .then(() => { + if (addChaptersMeta) { + return fs.unlink(metaFileName) + } + }) .then(() => { return resolve() }) diff --git a/packages/server/package.json b/packages/server/package.json index c313468df5..b3f531e5fe 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -127,6 +127,7 @@ "@cypress/debugging-proxy": "2.0.1", "@cypress/json-schemas": "5.35.0", "@cypress/sinon-chai": "1.1.0", + "@ffprobe-installer/ffprobe": "1.1.0", "@packages/desktop-gui": "*", "@packages/electron": "*", "@packages/example": "*", diff --git a/packages/server/test/e2e/6_video_compression_spec.js b/packages/server/test/e2e/6_video_compression_spec.js index 1899db1217..709c065e43 100644 --- a/packages/server/test/e2e/6_video_compression_spec.js +++ b/packages/server/test/e2e/6_video_compression_spec.js @@ -1,3 +1,11 @@ +// ffprobe is necessary to extract chapters data from mp4 files. +// ffprobe is usually installed with ffmpeg. +// But in our CI, it doesn't. That's why we're installing ffprobe here. +const ffprobePath = require('@ffprobe-installer/ffprobe').path +const ffmpeg = require('fluent-ffmpeg') + +ffmpeg.setFfprobePath(ffprobePath) + const humanInterval = require('human-interval') const e2e = require('../support/helpers/e2e').default const glob = require('../../lib/util/glob') @@ -33,7 +41,7 @@ describe('e2e video compression', () => { const videosPath = Fixtures.projectPath('e2e/cypress/videos/*') return glob(videosPath) - .then((files) => { + .tap((files) => { expect(files).to.have.length(1, `globbed for videos and found: ${files.length}. Expected to find 1 video. Search in videosPath: ${videosPath}.`) return videoCapture.getCodecData(files[0]) @@ -45,6 +53,25 @@ describe('e2e video compression', () => { expect(durationMs).to.be.closeTo(EXPECTED_DURATION_MS, humanInterval('15 seconds')) }) }) + .then((files) => { + return videoCapture.getChapters(files[0]) + .then(({ chapters }) => { + // There are 40 chapters but we test only the first one + // because what we want to check is if chapters are added properly. + // In a chapter object, there are properties like 'end' and 'end_time'. + // We don't check them here because they return the test time in milliseconds. + // They cannot be guessed correctly and they can cause flakiness. + expect(chapters[0].id).to.eq(0) + expect(chapters[0].start).to.eq(0) + expect(chapters[0].start_time).to.eq(0) + expect(chapters[0]['TAG:title']).to.eq('num: 1 makes some long tests') + expect(chapters[0].time_base).to.eq('1/1000') + expect(chapters[0].end).to.be.a('number') + expect(Number.isNaN(chapters[0].end)).to.be.false + expect(chapters[0].end_time).to.be.a('number') + expect(Number.isNaN(chapters[0].end_time)).to.be.false + }) + }) }).get('stdout') .then((stdout) => { expect(stdout).to.match(/Compression progress:\s+\d{1,3}%/) diff --git a/yarn.lock b/yarn.lock index a69c7a5021..a2a686827d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2493,6 +2493,54 @@ resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz#17e8699b5798d4c60e36e2d6326a8ebe5e95a2c5" integrity sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg== +"@ffprobe-installer/darwin-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/darwin-x64/-/darwin-x64-4.1.0.tgz#025c5108faf3e456e6a407dd65b798f8dcc805dd" + integrity sha512-ESwvOnbGVGK0r7bUdThSZAYipQOH0X79M4SoNZ5Tg77lq/RVbEdpObNEM2oRfLINbMlQQrezA4VYzt0n/DOkcQ== + +"@ffprobe-installer/ffprobe@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/ffprobe/-/ffprobe-1.1.0.tgz#a2f6fbd383f90d9359dc6c0552dca9793a884b3c" + integrity sha512-koiZrWEC4hrzCuN+/ijHkeiyx7CnUr/cnGynQAgHMDphsDgZkXivNzZrtT6VI5Nf0SkQqC2ZPU5rx3nZ8yEqLQ== + optionalDependencies: + "@ffprobe-installer/darwin-x64" "4.1.0" + "@ffprobe-installer/linux-arm" "4.3.2" + "@ffprobe-installer/linux-arm64" "4.3.2" + "@ffprobe-installer/linux-ia32" "4.1.0" + "@ffprobe-installer/linux-x64" "4.1.0" + "@ffprobe-installer/win32-ia32" "4.1.0" + "@ffprobe-installer/win32-x64" "4.1.0" + +"@ffprobe-installer/linux-arm64@4.3.2": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/linux-arm64/-/linux-arm64-4.3.2.tgz#a64ed27672d55460bdea59bc63da0cf3731f19e8" + integrity sha512-9mCINruqx30UqB7kRvc75sj0yAPiDy21Fowow8bQDaAYAuO39MrFt/caLJrX11vCUfx2awolxKeuzTqcO9JjMQ== + +"@ffprobe-installer/linux-arm@4.3.2": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/linux-arm/-/linux-arm-4.3.2.tgz#09347e67539544168d9815486cd543c3e88cba29" + integrity sha512-nZJbpTdh29swlgjVWi2fcV5jvbDFgo2y6a7X/uBsbely/TB158Fg0AncWJm7BbC0CwasGmSdqBsLtoSwXIcrlQ== + +"@ffprobe-installer/linux-ia32@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/linux-ia32/-/linux-ia32-4.1.0.tgz#50b5952927b8b1bc42c3f2edd788b995e8470d17" + integrity sha512-V2NeZpnly4HP1IU5IrsbbcRg8SWzC/SS0YDNSCjmhxGV2U8MUpW8c8KREE6nX56Dml8B8do5NNkTnaYCDPt3Xw== + +"@ffprobe-installer/linux-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/linux-x64/-/linux-x64-4.1.0.tgz#b67c96748457677171ca31b0fb83e4bd3c644ab5" + integrity sha512-Id+irHoI+Arq6tb3sHNQyzRrgUVVDgbmwpREDqQ+GDydiCw5ca7VnvRGXE/tBM2mVQ3/6m9wWHR7+xaW3gKJlA== + +"@ffprobe-installer/win32-ia32@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/win32-ia32/-/win32-ia32-4.1.0.tgz#19cc6d1043f7e54c2764ada6af8f7f26f98bbad8" + integrity sha512-G1pbRfk7XDi9EioT0gSR+O4ARdppS9kSXRzhnJOojUFD6x1k8Qv27RoOXeE5DtIE7TdX6UTywj8qA1BXI5zUUA== + +"@ffprobe-installer/win32-x64@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@ffprobe-installer/win32-x64/-/win32-x64-4.1.0.tgz#ed3e8a329eeb6c0625ac439e31ad9ec43001b33c" + integrity sha512-gPW2FZxexzCAOhGch0JFkeSSln+wcL5d1JDlJwfSJVEAShHf9MmxiWq0NpHoCSzFvK5qwl0C58KG180eKvd3mA== + "@fortawesome/fontawesome-free@5.11.2": version "5.11.2" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz#8644bc25b19475779a7b7c1fc104bc0a794f4465"