1 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
3 import { expect } from 'chai'
4 import { canDoQuickTranscode } from '@server/lib/transcoding/transcoding-quick-transcode'
5 import { checkWebTorrentWorks, generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared'
6 import { buildAbsoluteFixturePath, getAllFiles, getMaxTheoreticalBitrate, getMinTheoreticalBitrate, omit } from '@shared/core-utils'
10 getVideoStreamBitrate,
11 getVideoStreamDimensionsInfo,
14 } from '@shared/ffmpeg'
15 import { HttpStatusCode, VideoFileMetadata, VideoState } from '@shared/models'
18 createMultipleServers,
22 setAccessTokensToServers,
24 } from '@shared/server-commands'
26 function updateConfigForTranscoding (server: PeerTubeServer) {
27 return server.config.updateCustomSubConfig({
31 allowAdditionalExtensions: true,
32 allowAudioFiles: true,
33 hls: { enabled: true },
34 webtorrent: { enabled: true },
51 describe('Test video transcoding', function () {
52 let servers: PeerTubeServer[] = []
55 before(async function () {
59 servers = await createMultipleServers(2)
61 await setAccessTokensToServers(servers)
63 await doubleFollow(servers[0], servers[1])
65 await updateConfigForTranscoding(servers[1])
68 describe('Basic transcoding (or not)', function () {
70 it('Should not transcode video on server 1', async function () {
74 name: 'my super name for server 1',
75 description: 'my super description for server 1',
76 fixture: 'video_short.webm'
78 await servers[0].videos.upload({ attributes })
80 await waitJobs(servers)
82 for (const server of servers) {
83 const { data } = await server.videos.list()
86 const videoDetails = await server.videos.get({ id: video.id })
87 expect(videoDetails.files).to.have.lengthOf(1)
89 const magnetUri = videoDetails.files[0].magnetUri
90 expect(magnetUri).to.match(/\.webm/)
92 await checkWebTorrentWorks(magnetUri, /\.webm$/)
96 it('Should transcode video on server 2', async function () {
100 name: 'my super name for server 2',
101 description: 'my super description for server 2',
102 fixture: 'video_short.webm'
104 await servers[1].videos.upload({ attributes })
106 await waitJobs(servers)
108 for (const server of servers) {
109 const { data } = await server.videos.list()
111 const video = data.find(v => v.name === attributes.name)
112 const videoDetails = await server.videos.get({ id: video.id })
114 expect(videoDetails.files).to.have.lengthOf(5)
116 const magnetUri = videoDetails.files[0].magnetUri
117 expect(magnetUri).to.match(/\.mp4/)
119 await checkWebTorrentWorks(magnetUri, /\.mp4$/)
123 it('Should wait for transcoding before publishing the video', async function () {
124 this.timeout(160_000)
127 // Upload the video, but wait transcoding
129 name: 'waiting video',
130 fixture: 'video_short1.webm',
131 waitTranscoding: true
133 const { uuid } = await servers[1].videos.upload({ attributes })
136 // Should be in transcode state
137 const body = await servers[1].videos.get({ id: videoId })
138 expect(body.name).to.equal('waiting video')
139 expect(body.state.id).to.equal(VideoState.TO_TRANSCODE)
140 expect(body.state.label).to.equal('To transcode')
141 expect(body.waitTranscoding).to.be.true
144 // Should have my video
145 const { data } = await servers[1].videos.listMyVideos()
146 const videoToFindInMine = data.find(v => v.name === attributes.name)
147 expect(videoToFindInMine).not.to.be.undefined
148 expect(videoToFindInMine.state.id).to.equal(VideoState.TO_TRANSCODE)
149 expect(videoToFindInMine.state.label).to.equal('To transcode')
150 expect(videoToFindInMine.waitTranscoding).to.be.true
154 // Should not list this video
155 const { data } = await servers[1].videos.list()
156 const videoToFindInList = data.find(v => v.name === attributes.name)
157 expect(videoToFindInList).to.be.undefined
160 // Server 1 should not have the video yet
161 await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
164 await waitJobs(servers)
166 for (const server of servers) {
167 const { data } = await server.videos.list()
168 const videoToFind = data.find(v => v.name === 'waiting video')
169 expect(videoToFind).not.to.be.undefined
171 const videoDetails = await server.videos.get({ id: videoToFind.id })
173 expect(videoDetails.state.id).to.equal(VideoState.PUBLISHED)
174 expect(videoDetails.state.label).to.equal('Published')
175 expect(videoDetails.waitTranscoding).to.be.true
179 it('Should accept and transcode additional extensions', async function () {
180 this.timeout(300_000)
182 for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) {
188 await servers[1].videos.upload({ attributes })
190 await waitJobs(servers)
192 for (const server of servers) {
193 const { data } = await server.videos.list()
195 const video = data.find(v => v.name === attributes.name)
196 const videoDetails = await server.videos.get({ id: video.id })
197 expect(videoDetails.files).to.have.lengthOf(5)
199 const magnetUri = videoDetails.files[0].magnetUri
200 expect(magnetUri).to.contain('.mp4')
205 it('Should transcode a 4k video', async function () {
206 this.timeout(200_000)
210 fixture: 'video_short_4k.mp4'
213 const { uuid } = await servers[1].videos.upload({ attributes })
216 await waitJobs(servers)
218 const resolutions = [ 144, 240, 360, 480, 720, 1080, 1440, 2160 ]
220 for (const server of servers) {
221 const videoDetails = await server.videos.get({ id: video4k })
222 expect(videoDetails.files).to.have.lengthOf(resolutions.length)
224 for (const r of resolutions) {
225 expect(videoDetails.files.find(f => f.resolution.id === r)).to.not.be.undefined
226 expect(videoDetails.streamingPlaylists[0].files.find(f => f.resolution.id === r)).to.not.be.undefined
232 describe('Audio transcoding', function () {
234 it('Should transcode high bit rate mp3 to proper bit rate', async function () {
239 fixture: 'video_short_mp3_256k.mp4'
241 await servers[1].videos.upload({ attributes })
243 await waitJobs(servers)
245 for (const server of servers) {
246 const { data } = await server.videos.list()
248 const video = data.find(v => v.name === attributes.name)
249 const videoDetails = await server.videos.get({ id: video.id })
251 expect(videoDetails.files).to.have.lengthOf(5)
253 const file = videoDetails.files.find(f => f.resolution.id === 240)
254 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
255 const probe = await getAudioStream(path)
257 if (probe.audioStream) {
258 expect(probe.audioStream['codec_name']).to.be.equal('aac')
259 expect(probe.audioStream['bit_rate']).to.be.at.most(384 * 8000)
261 this.fail('Could not retrieve the audio stream on ' + probe.absolutePath)
266 it('Should transcode video with no audio and have no audio itself', async function () {
271 fixture: 'video_short_no_audio.mp4'
273 await servers[1].videos.upload({ attributes })
275 await waitJobs(servers)
277 for (const server of servers) {
278 const { data } = await server.videos.list()
280 const video = data.find(v => v.name === attributes.name)
281 const videoDetails = await server.videos.get({ id: video.id })
283 const file = videoDetails.files.find(f => f.resolution.id === 240)
284 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
286 expect(await hasAudioStream(path)).to.be.false
290 it('Should leave the audio untouched, but properly transcode the video', async function () {
294 name: 'untouched_audio',
295 fixture: 'video_short.mp4'
297 await servers[1].videos.upload({ attributes })
299 await waitJobs(servers)
301 for (const server of servers) {
302 const { data } = await server.videos.list()
304 const video = data.find(v => v.name === attributes.name)
305 const videoDetails = await server.videos.get({ id: video.id })
307 expect(videoDetails.files).to.have.lengthOf(5)
309 const fixturePath = buildAbsoluteFixturePath(attributes.fixture)
310 const fixtureVideoProbe = await getAudioStream(fixturePath)
312 const file = videoDetails.files.find(f => f.resolution.id === 240)
313 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
315 const videoProbe = await getAudioStream(path)
317 if (videoProbe.audioStream && fixtureVideoProbe.audioStream) {
318 const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ]
319 expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit))
321 this.fail('Could not retrieve the audio stream on ' + videoProbe.absolutePath)
327 describe('Audio upload', function () {
329 function runSuite (mode: 'legacy' | 'resumable') {
331 before(async function () {
332 await servers[1].config.updateCustomSubConfig({
335 hls: { enabled: true },
336 webtorrent: { enabled: true },
353 it('Should merge an audio file with the preview file', async function () {
356 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
357 await servers[1].videos.upload({ attributes, mode })
359 await waitJobs(servers)
361 for (const server of servers) {
362 const { data } = await server.videos.list()
364 const video = data.find(v => v.name === 'audio_with_preview')
365 const videoDetails = await server.videos.get({ id: video.id })
367 expect(videoDetails.files).to.have.lengthOf(1)
369 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
370 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 })
372 const magnetUri = videoDetails.files[0].magnetUri
373 expect(magnetUri).to.contain('.mp4')
377 it('Should upload an audio file and choose a default background image', async function () {
380 const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
381 await servers[1].videos.upload({ attributes, mode })
383 await waitJobs(servers)
385 for (const server of servers) {
386 const { data } = await server.videos.list()
388 const video = data.find(v => v.name === 'audio_without_preview')
389 const videoDetails = await server.videos.get({ id: video.id })
391 expect(videoDetails.files).to.have.lengthOf(1)
393 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
394 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 })
396 const magnetUri = videoDetails.files[0].magnetUri
397 expect(magnetUri).to.contain('.mp4')
401 it('Should upload an audio file and create an audio version only', async function () {
404 await servers[1].config.updateCustomSubConfig({
407 hls: { enabled: true },
408 webtorrent: { enabled: true },
419 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
420 const { id } = await servers[1].videos.upload({ attributes, mode })
422 await waitJobs(servers)
424 for (const server of servers) {
425 const videoDetails = await server.videos.get({ id })
427 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) {
428 expect(files).to.have.lengthOf(2)
429 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined
433 await updateConfigForTranscoding(servers[1])
437 describe('Legacy upload', function () {
441 describe('Resumable upload', function () {
442 runSuite('resumable')
446 describe('Framerate', function () {
448 it('Should transcode a 60 FPS video', async function () {
452 name: 'my super 30fps name for server 2',
453 description: 'my super 30fps description for server 2',
454 fixture: '60fps_720p_small.mp4'
456 await servers[1].videos.upload({ attributes })
458 await waitJobs(servers)
460 for (const server of servers) {
461 const { data } = await server.videos.list()
463 const video = data.find(v => v.name === attributes.name)
464 const videoDetails = await server.videos.get({ id: video.id })
466 expect(videoDetails.files).to.have.lengthOf(5)
467 expect(videoDetails.files[0].fps).to.be.above(58).and.below(62)
468 expect(videoDetails.files[1].fps).to.be.below(31)
469 expect(videoDetails.files[2].fps).to.be.below(31)
470 expect(videoDetails.files[3].fps).to.be.below(31)
471 expect(videoDetails.files[4].fps).to.be.below(31)
473 for (const resolution of [ 144, 240, 360, 480 ]) {
474 const file = videoDetails.files.find(f => f.resolution.id === resolution)
475 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
476 const fps = await getVideoStreamFPS(path)
478 expect(fps).to.be.below(31)
481 const file = videoDetails.files.find(f => f.resolution.id === 720)
482 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
483 const fps = await getVideoStreamFPS(path)
485 expect(fps).to.be.above(58).and.below(62)
489 it('Should downscale to the closest divisor standard framerate', async function () {
490 this.timeout(200_000)
492 let tempFixturePath: string
495 tempFixturePath = await generateVideoWithFramerate(59)
497 const fps = await getVideoStreamFPS(tempFixturePath)
498 expect(fps).to.be.equal(59)
503 description: '59fps video',
504 fixture: tempFixturePath
507 await servers[1].videos.upload({ attributes })
509 await waitJobs(servers)
511 for (const server of servers) {
512 const { data } = await server.videos.list()
514 const { id } = data.find(v => v.name === attributes.name)
515 const video = await server.videos.get({ id })
518 const file = video.files.find(f => f.resolution.id === 240)
519 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
520 const fps = await getVideoStreamFPS(path)
521 expect(fps).to.be.equal(25)
525 const file = video.files.find(f => f.resolution.id === 720)
526 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
527 const fps = await getVideoStreamFPS(path)
528 expect(fps).to.be.equal(59)
534 describe('Bitrate control', function () {
536 it('Should respect maximum bitrate values', async function () {
537 this.timeout(160_000)
539 const tempFixturePath = await generateHighBitrateVideo()
542 name: 'high bitrate video',
543 description: 'high bitrate video',
544 fixture: tempFixturePath
547 await servers[1].videos.upload({ attributes })
549 await waitJobs(servers)
551 for (const server of servers) {
552 const { data } = await server.videos.list()
554 const { id } = data.find(v => v.name === attributes.name)
555 const video = await server.videos.get({ id })
557 for (const resolution of [ 240, 360, 480, 720, 1080 ]) {
558 const file = video.files.find(f => f.resolution.id === resolution)
559 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
561 const bitrate = await getVideoStreamBitrate(path)
562 const fps = await getVideoStreamFPS(path)
563 const dataResolution = await getVideoStreamDimensionsInfo(path)
565 expect(resolution).to.equal(resolution)
567 const maxBitrate = getMaxTheoreticalBitrate({ ...dataResolution, fps })
568 expect(bitrate).to.be.below(maxBitrate)
573 it('Should not transcode to an higher bitrate than the original file but above our low limit', async function () {
574 this.timeout(160_000)
589 webtorrent: { enabled: true },
590 hls: { enabled: true }
593 await servers[1].config.updateCustomSubConfig({ newConfig })
597 fixture: 'low-bitrate.mp4'
600 const { id } = await servers[1].videos.upload({ attributes })
602 await waitJobs(servers)
604 const video = await servers[1].videos.get({ id })
606 const resolutions = [ 240, 360, 480, 720, 1080 ]
607 for (const r of resolutions) {
608 const file = video.files.find(f => f.resolution.id === r)
610 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
611 const bitrate = await getVideoStreamBitrate(path)
613 const inputBitrate = 60_000
614 const limit = getMinTheoreticalBitrate({ fps: 10, ratio: 1, resolution: r })
615 let belowValue = Math.max(inputBitrate, limit)
616 belowValue += belowValue * 0.20 // Apply 20% margin because bitrate control is not very precise
618 expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue)
623 describe('FFprobe', function () {
625 it('Should provide valid ffprobe data', async function () {
626 this.timeout(160_000)
628 const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid
629 await waitJobs(servers)
632 const video = await servers[1].videos.get({ id: videoUUID })
633 const file = video.files.find(f => f.resolution.id === 240)
634 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
636 const probe = await ffprobePromise(path)
637 const metadata = new VideoFileMetadata(probe)
639 // expected format properties
646 expect(metadata.format).to.have.nested.property(p)
649 // expected stream properties
655 'display_aspect_ratio',
659 expect(metadata.streams[0]).to.have.nested.property(p)
662 expect(metadata).to.not.have.nested.property('format.filename')
665 for (const server of servers) {
666 const videoDetails = await server.videos.get({ id: videoUUID })
668 const videoFiles = getAllFiles(videoDetails)
669 expect(videoFiles).to.have.lengthOf(10)
671 for (const file of videoFiles) {
672 expect(file.metadata).to.be.undefined
673 expect(file.metadataUrl).to.exist
674 expect(file.metadataUrl).to.contain(servers[1].url)
675 expect(file.metadataUrl).to.contain(videoUUID)
677 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
678 expect(metadata).to.have.nested.property('format.size')
683 it('Should correctly detect if quick transcode is possible', async function () {
686 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true
687 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false
691 describe('Transcoding job queue', function () {
693 it('Should have the appropriate priorities for transcoding jobs', async function () {
694 const body = await servers[1].jobs.list({
698 jobType: 'video-transcoding'
701 const jobs = body.data
702 const transcodingJobs = jobs.filter(j => j.data.videoUUID === video4k)
704 expect(transcodingJobs).to.have.lengthOf(16)
706 const hlsJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-hls')
707 const webtorrentJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-webtorrent')
708 const optimizeJobs = transcodingJobs.filter(j => j.data.type === 'optimize-to-webtorrent')
710 expect(hlsJobs).to.have.lengthOf(8)
711 expect(webtorrentJobs).to.have.lengthOf(7)
712 expect(optimizeJobs).to.have.lengthOf(1)
714 for (const j of optimizeJobs.concat(hlsJobs.concat(webtorrentJobs))) {
715 expect(j.priority).to.be.greaterThan(100)
716 expect(j.priority).to.be.lessThan(150)
721 describe('Bounded transcoding', function () {
723 it('Should not generate an upper resolution than original file', async function () {
724 this.timeout(120_000)
726 await servers[0].config.updateExistingSubConfig({
730 hls: { enabled: true },
731 webtorrent: { enabled: true },
743 alwaysTranscodeOriginalResolution: false
748 const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' })
749 await waitJobs(servers)
751 const video = await servers[0].videos.get({ id: uuid })
752 const hlsFiles = video.streamingPlaylists[0].files
754 expect(video.files).to.have.lengthOf(2)
755 expect(hlsFiles).to.have.lengthOf(2)
757 // eslint-disable-next-line @typescript-eslint/require-array-sort-compare
758 const resolutions = getAllFiles(video).map(f => f.resolution.id).sort()
759 expect(resolutions).to.deep.equal([ 240, 240, 480, 480 ])
762 it('Should only keep the original resolution if all resolutions are disabled', async function () {
763 this.timeout(120_000)
765 await servers[0].config.updateExistingSubConfig({
783 const { uuid } = await servers[0].videos.quickUpload({ name: 'video', fixture: 'video_short.webm' })
784 await waitJobs(servers)
786 const video = await servers[0].videos.get({ id: uuid })
787 const hlsFiles = video.streamingPlaylists[0].files
789 expect(video.files).to.have.lengthOf(1)
790 expect(hlsFiles).to.have.lengthOf(1)
792 expect(video.files[0].resolution.id).to.equal(720)
793 expect(hlsFiles[0].resolution.id).to.equal(720)
797 after(async function () {
798 await cleanupTests(servers)