1 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
3 import { expect } from 'chai'
4 import { canDoQuickTranscode } from '@server/helpers/ffmpeg'
5 import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared'
6 import { buildAbsoluteFixturePath, getAllFiles, getMaxBitrate, getMinLimitBitrate, omit } from '@shared/core-utils'
10 getVideoStreamBitrate,
11 getVideoStreamDimensionsInfo,
14 } from '@shared/extra-utils'
15 import { HttpStatusCode, VideoState } from '@shared/models'
18 createMultipleServers,
22 setAccessTokensToServers,
25 } from '@shared/server-commands'
27 function updateConfigForTranscoding (server: PeerTubeServer) {
28 return server.config.updateCustomSubConfig({
32 allowAdditionalExtensions: true,
33 allowAudioFiles: true,
34 hls: { enabled: true },
35 webtorrent: { enabled: true },
52 describe('Test video transcoding', function () {
53 let servers: PeerTubeServer[] = []
56 before(async function () {
60 servers = await createMultipleServers(2)
62 await setAccessTokensToServers(servers)
64 await doubleFollow(servers[0], servers[1])
66 await updateConfigForTranscoding(servers[1])
69 describe('Basic transcoding (or not)', function () {
71 it('Should not transcode video on server 1', async function () {
75 name: 'my super name for server 1',
76 description: 'my super description for server 1',
77 fixture: 'video_short.webm'
79 await servers[0].videos.upload({ attributes })
81 await waitJobs(servers)
83 for (const server of servers) {
84 const { data } = await server.videos.list()
87 const videoDetails = await server.videos.get({ id: video.id })
88 expect(videoDetails.files).to.have.lengthOf(1)
90 const magnetUri = videoDetails.files[0].magnetUri
91 expect(magnetUri).to.match(/\.webm/)
93 const torrent = await webtorrentAdd(magnetUri, true)
94 expect(torrent.files).to.be.an('array')
95 expect(torrent.files.length).to.equal(1)
96 expect(torrent.files[0].path).match(/\.webm$/)
100 it('Should transcode video on server 2', async function () {
101 this.timeout(120_000)
104 name: 'my super name for server 2',
105 description: 'my super description for server 2',
106 fixture: 'video_short.webm'
108 await servers[1].videos.upload({ attributes })
110 await waitJobs(servers)
112 for (const server of servers) {
113 const { data } = await server.videos.list()
115 const video = data.find(v => v.name === attributes.name)
116 const videoDetails = await server.videos.get({ id: video.id })
118 expect(videoDetails.files).to.have.lengthOf(5)
120 const magnetUri = videoDetails.files[0].magnetUri
121 expect(magnetUri).to.match(/\.mp4/)
123 const torrent = await webtorrentAdd(magnetUri, true)
124 expect(torrent.files).to.be.an('array')
125 expect(torrent.files.length).to.equal(1)
126 expect(torrent.files[0].path).match(/\.mp4$/)
130 it('Should wait for transcoding before publishing the video', async function () {
131 this.timeout(160_000)
134 // Upload the video, but wait transcoding
136 name: 'waiting video',
137 fixture: 'video_short1.webm',
138 waitTranscoding: true
140 const { uuid } = await servers[1].videos.upload({ attributes })
143 // Should be in transcode state
144 const body = await servers[1].videos.get({ id: videoId })
145 expect(body.name).to.equal('waiting video')
146 expect(body.state.id).to.equal(VideoState.TO_TRANSCODE)
147 expect(body.state.label).to.equal('To transcode')
148 expect(body.waitTranscoding).to.be.true
151 // Should have my video
152 const { data } = await servers[1].videos.listMyVideos()
153 const videoToFindInMine = data.find(v => v.name === attributes.name)
154 expect(videoToFindInMine).not.to.be.undefined
155 expect(videoToFindInMine.state.id).to.equal(VideoState.TO_TRANSCODE)
156 expect(videoToFindInMine.state.label).to.equal('To transcode')
157 expect(videoToFindInMine.waitTranscoding).to.be.true
161 // Should not list this video
162 const { data } = await servers[1].videos.list()
163 const videoToFindInList = data.find(v => v.name === attributes.name)
164 expect(videoToFindInList).to.be.undefined
167 // Server 1 should not have the video yet
168 await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
171 await waitJobs(servers)
173 for (const server of servers) {
174 const { data } = await server.videos.list()
175 const videoToFind = data.find(v => v.name === 'waiting video')
176 expect(videoToFind).not.to.be.undefined
178 const videoDetails = await server.videos.get({ id: videoToFind.id })
180 expect(videoDetails.state.id).to.equal(VideoState.PUBLISHED)
181 expect(videoDetails.state.label).to.equal('Published')
182 expect(videoDetails.waitTranscoding).to.be.true
186 it('Should accept and transcode additional extensions', async function () {
187 this.timeout(300_000)
189 for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) {
195 await servers[1].videos.upload({ attributes })
197 await waitJobs(servers)
199 for (const server of servers) {
200 const { data } = await server.videos.list()
202 const video = data.find(v => v.name === attributes.name)
203 const videoDetails = await server.videos.get({ id: video.id })
204 expect(videoDetails.files).to.have.lengthOf(5)
206 const magnetUri = videoDetails.files[0].magnetUri
207 expect(magnetUri).to.contain('.mp4')
212 it('Should transcode a 4k video', async function () {
213 this.timeout(200_000)
217 fixture: 'video_short_4k.mp4'
220 const { uuid } = await servers[1].videos.upload({ attributes })
223 await waitJobs(servers)
225 const resolutions = [ 144, 240, 360, 480, 720, 1080, 1440, 2160 ]
227 for (const server of servers) {
228 const videoDetails = await server.videos.get({ id: video4k })
229 expect(videoDetails.files).to.have.lengthOf(resolutions.length)
231 for (const r of resolutions) {
232 expect(videoDetails.files.find(f => f.resolution.id === r)).to.not.be.undefined
233 expect(videoDetails.streamingPlaylists[0].files.find(f => f.resolution.id === r)).to.not.be.undefined
239 describe('Audio transcoding', function () {
241 it('Should transcode high bit rate mp3 to proper bit rate', async function () {
246 fixture: 'video_short_mp3_256k.mp4'
248 await servers[1].videos.upload({ attributes })
250 await waitJobs(servers)
252 for (const server of servers) {
253 const { data } = await server.videos.list()
255 const video = data.find(v => v.name === attributes.name)
256 const videoDetails = await server.videos.get({ id: video.id })
258 expect(videoDetails.files).to.have.lengthOf(5)
260 const file = videoDetails.files.find(f => f.resolution.id === 240)
261 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
262 const probe = await getAudioStream(path)
264 if (probe.audioStream) {
265 expect(probe.audioStream['codec_name']).to.be.equal('aac')
266 expect(probe.audioStream['bit_rate']).to.be.at.most(384 * 8000)
268 this.fail('Could not retrieve the audio stream on ' + probe.absolutePath)
273 it('Should transcode video with no audio and have no audio itself', async function () {
278 fixture: 'video_short_no_audio.mp4'
280 await servers[1].videos.upload({ attributes })
282 await waitJobs(servers)
284 for (const server of servers) {
285 const { data } = await server.videos.list()
287 const video = data.find(v => v.name === attributes.name)
288 const videoDetails = await server.videos.get({ id: video.id })
290 const file = videoDetails.files.find(f => f.resolution.id === 240)
291 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
293 expect(await hasAudioStream(path)).to.be.false
297 it('Should leave the audio untouched, but properly transcode the video', async function () {
301 name: 'untouched_audio',
302 fixture: 'video_short.mp4'
304 await servers[1].videos.upload({ attributes })
306 await waitJobs(servers)
308 for (const server of servers) {
309 const { data } = await server.videos.list()
311 const video = data.find(v => v.name === attributes.name)
312 const videoDetails = await server.videos.get({ id: video.id })
314 expect(videoDetails.files).to.have.lengthOf(5)
316 const fixturePath = buildAbsoluteFixturePath(attributes.fixture)
317 const fixtureVideoProbe = await getAudioStream(fixturePath)
319 const file = videoDetails.files.find(f => f.resolution.id === 240)
320 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
322 const videoProbe = await getAudioStream(path)
324 if (videoProbe.audioStream && fixtureVideoProbe.audioStream) {
325 const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ]
326 expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit))
328 this.fail('Could not retrieve the audio stream on ' + videoProbe.absolutePath)
334 describe('Audio upload', function () {
336 function runSuite (mode: 'legacy' | 'resumable') {
338 before(async function () {
339 await servers[1].config.updateCustomSubConfig({
342 hls: { enabled: true },
343 webtorrent: { enabled: true },
360 it('Should merge an audio file with the preview file', async function () {
363 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
364 await servers[1].videos.upload({ attributes, mode })
366 await waitJobs(servers)
368 for (const server of servers) {
369 const { data } = await server.videos.list()
371 const video = data.find(v => v.name === 'audio_with_preview')
372 const videoDetails = await server.videos.get({ id: video.id })
374 expect(videoDetails.files).to.have.lengthOf(1)
376 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
377 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 })
379 const magnetUri = videoDetails.files[0].magnetUri
380 expect(magnetUri).to.contain('.mp4')
384 it('Should upload an audio file and choose a default background image', async function () {
387 const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
388 await servers[1].videos.upload({ attributes, mode })
390 await waitJobs(servers)
392 for (const server of servers) {
393 const { data } = await server.videos.list()
395 const video = data.find(v => v.name === 'audio_without_preview')
396 const videoDetails = await server.videos.get({ id: video.id })
398 expect(videoDetails.files).to.have.lengthOf(1)
400 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
401 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 })
403 const magnetUri = videoDetails.files[0].magnetUri
404 expect(magnetUri).to.contain('.mp4')
408 it('Should upload an audio file and create an audio version only', async function () {
411 await servers[1].config.updateCustomSubConfig({
414 hls: { enabled: true },
415 webtorrent: { enabled: true },
426 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
427 const { id } = await servers[1].videos.upload({ attributes, mode })
429 await waitJobs(servers)
431 for (const server of servers) {
432 const videoDetails = await server.videos.get({ id })
434 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) {
435 expect(files).to.have.lengthOf(2)
436 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined
440 await updateConfigForTranscoding(servers[1])
444 describe('Legacy upload', function () {
448 describe('Resumable upload', function () {
449 runSuite('resumable')
453 describe('Framerate', function () {
455 it('Should transcode a 60 FPS video', async function () {
459 name: 'my super 30fps name for server 2',
460 description: 'my super 30fps description for server 2',
461 fixture: '60fps_720p_small.mp4'
463 await servers[1].videos.upload({ attributes })
465 await waitJobs(servers)
467 for (const server of servers) {
468 const { data } = await server.videos.list()
470 const video = data.find(v => v.name === attributes.name)
471 const videoDetails = await server.videos.get({ id: video.id })
473 expect(videoDetails.files).to.have.lengthOf(5)
474 expect(videoDetails.files[0].fps).to.be.above(58).and.below(62)
475 expect(videoDetails.files[1].fps).to.be.below(31)
476 expect(videoDetails.files[2].fps).to.be.below(31)
477 expect(videoDetails.files[3].fps).to.be.below(31)
478 expect(videoDetails.files[4].fps).to.be.below(31)
480 for (const resolution of [ 144, 240, 360, 480 ]) {
481 const file = videoDetails.files.find(f => f.resolution.id === resolution)
482 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
483 const fps = await getVideoStreamFPS(path)
485 expect(fps).to.be.below(31)
488 const file = videoDetails.files.find(f => f.resolution.id === 720)
489 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
490 const fps = await getVideoStreamFPS(path)
492 expect(fps).to.be.above(58).and.below(62)
496 it('Should downscale to the closest divisor standard framerate', async function () {
497 this.timeout(200_000)
499 let tempFixturePath: string
502 tempFixturePath = await generateVideoWithFramerate(59)
504 const fps = await getVideoStreamFPS(tempFixturePath)
505 expect(fps).to.be.equal(59)
510 description: '59fps video',
511 fixture: tempFixturePath
514 await servers[1].videos.upload({ attributes })
516 await waitJobs(servers)
518 for (const server of servers) {
519 const { data } = await server.videos.list()
521 const { id } = data.find(v => v.name === attributes.name)
522 const video = await server.videos.get({ id })
525 const file = video.files.find(f => f.resolution.id === 240)
526 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
527 const fps = await getVideoStreamFPS(path)
528 expect(fps).to.be.equal(25)
532 const file = video.files.find(f => f.resolution.id === 720)
533 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
534 const fps = await getVideoStreamFPS(path)
535 expect(fps).to.be.equal(59)
541 describe('Bitrate control', function () {
543 it('Should respect maximum bitrate values', async function () {
544 this.timeout(160_000)
546 const tempFixturePath = await generateHighBitrateVideo()
549 name: 'high bitrate video',
550 description: 'high bitrate video',
551 fixture: tempFixturePath
554 await servers[1].videos.upload({ attributes })
556 await waitJobs(servers)
558 for (const server of servers) {
559 const { data } = await server.videos.list()
561 const { id } = data.find(v => v.name === attributes.name)
562 const video = await server.videos.get({ id })
564 for (const resolution of [ 240, 360, 480, 720, 1080 ]) {
565 const file = video.files.find(f => f.resolution.id === resolution)
566 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
568 const bitrate = await getVideoStreamBitrate(path)
569 const fps = await getVideoStreamFPS(path)
570 const dataResolution = await getVideoStreamDimensionsInfo(path)
572 expect(resolution).to.equal(resolution)
574 const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
575 expect(bitrate).to.be.below(maxBitrate)
580 it('Should not transcode to an higher bitrate than the original file but above our low limit', async function () {
581 this.timeout(160_000)
596 webtorrent: { enabled: true },
597 hls: { enabled: true }
600 await servers[1].config.updateCustomSubConfig({ newConfig })
604 fixture: 'low-bitrate.mp4'
607 const { id } = await servers[1].videos.upload({ attributes })
609 await waitJobs(servers)
611 const video = await servers[1].videos.get({ id })
613 const resolutions = [ 240, 360, 480, 720, 1080 ]
614 for (const r of resolutions) {
615 const file = video.files.find(f => f.resolution.id === r)
617 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
618 const bitrate = await getVideoStreamBitrate(path)
620 const inputBitrate = 60_000
621 const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r })
622 let belowValue = Math.max(inputBitrate, limit)
623 belowValue += belowValue * 0.20 // Apply 20% margin because bitrate control is not very precise
625 expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue)
630 describe('FFprobe', function () {
632 it('Should provide valid ffprobe data', async function () {
633 this.timeout(160_000)
635 const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid
636 await waitJobs(servers)
639 const video = await servers[1].videos.get({ id: videoUUID })
640 const file = video.files.find(f => f.resolution.id === 240)
641 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
642 const metadata = await buildFileMetadata(path)
644 // expected format properties
651 expect(metadata.format).to.have.nested.property(p)
654 // expected stream properties
660 'display_aspect_ratio',
664 expect(metadata.streams[0]).to.have.nested.property(p)
667 expect(metadata).to.not.have.nested.property('format.filename')
670 for (const server of servers) {
671 const videoDetails = await server.videos.get({ id: videoUUID })
673 const videoFiles = getAllFiles(videoDetails)
674 expect(videoFiles).to.have.lengthOf(10)
676 for (const file of videoFiles) {
677 expect(file.metadata).to.be.undefined
678 expect(file.metadataUrl).to.exist
679 expect(file.metadataUrl).to.contain(servers[1].url)
680 expect(file.metadataUrl).to.contain(videoUUID)
682 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
683 expect(metadata).to.have.nested.property('format.size')
688 it('Should correctly detect if quick transcode is possible', async function () {
691 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true
692 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false
696 describe('Transcoding job queue', function () {
698 it('Should have the appropriate priorities for transcoding jobs', async function () {
699 const body = await servers[1].jobs.list({
703 jobType: 'video-transcoding'
706 const jobs = body.data
707 const transcodingJobs = jobs.filter(j => j.data.videoUUID === video4k)
709 expect(transcodingJobs).to.have.lengthOf(16)
711 const hlsJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-hls')
712 const webtorrentJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-webtorrent')
713 const optimizeJobs = transcodingJobs.filter(j => j.data.type === 'optimize-to-webtorrent')
715 expect(hlsJobs).to.have.lengthOf(8)
716 expect(webtorrentJobs).to.have.lengthOf(7)
717 expect(optimizeJobs).to.have.lengthOf(1)
719 for (const j of optimizeJobs.concat(hlsJobs.concat(webtorrentJobs))) {
720 expect(j.priority).to.be.greaterThan(100)
721 expect(j.priority).to.be.lessThan(150)
726 describe('Bounded transcoding', function () {
728 it('Should not generate an upper resolution than original file', async function () {
729 this.timeout(120_000)
731 await servers[0].config.updateExistingSubConfig({
735 hls: { enabled: true },
736 webtorrent: { enabled: true },
748 alwaysTranscodeOriginalResolution: false
753 const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' })
754 await waitJobs(servers)
756 const video = await servers[0].videos.get({ id: uuid })
757 const hlsFiles = video.streamingPlaylists[0].files
759 expect(video.files).to.have.lengthOf(2)
760 expect(hlsFiles).to.have.lengthOf(2)
762 // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
763 const resolutions = getAllFiles(video).map(f => f.resolution.id).sort()
764 expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ])
767 it('Should only keep the original resolution if all resolutions are disabled', async function () {
768 this.timeout(120_000)
770 await servers[0].config.updateExistingSubConfig({
788 const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' })
789 await waitJobs(servers)
791 const video = await servers[0].videos.get({ id: uuid })
792 const hlsFiles = video.streamingPlaylists[0].files
794 expect(video.files).to.have.lengthOf(1)
795 expect(hlsFiles).to.have.lengthOf(1)
797 expect(video.files[0].resolution.id).to.equal(720)
798 expect(hlsFiles[0].resolution.id).to.equal(720)
802 after(async function () {
803 await cleanupTests(servers)