1 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
4 import * as chai from 'chai'
5 import { omit } from 'lodash'
6 import { canDoQuickTranscode } from '@server/helpers/ffprobe-utils'
7 import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared'
8 import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils'
9 import { getAudioStream, getMetadataFromFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils'
10 import { HttpStatusCode, VideoState } from '@shared/models'
13 createMultipleServers,
17 setAccessTokensToServers,
20 } from '@shared/server-commands'
22 const expect = chai.expect
24 function updateConfigForTranscoding (server: PeerTubeServer) {
25 return server.config.updateCustomSubConfig({
29 allowAdditionalExtensions: true,
30 allowAudioFiles: true,
31 hls: { enabled: true },
32 webtorrent: { enabled: true },
49 describe('Test video transcoding', function () {
50 let servers: PeerTubeServer[] = []
53 before(async function () {
57 servers = await createMultipleServers(2)
59 await setAccessTokensToServers(servers)
61 await doubleFollow(servers[0], servers[1])
63 await updateConfigForTranscoding(servers[1])
66 describe('Basic transcoding (or not)', function () {
68 it('Should not transcode video on server 1', async function () {
72 name: 'my super name for server 1',
73 description: 'my super description for server 1',
74 fixture: 'video_short.webm'
76 await servers[0].videos.upload({ attributes })
78 await waitJobs(servers)
80 for (const server of servers) {
81 const { data } = await server.videos.list()
84 const videoDetails = await server.videos.get({ id: video.id })
85 expect(videoDetails.files).to.have.lengthOf(1)
87 const magnetUri = videoDetails.files[0].magnetUri
88 expect(magnetUri).to.match(/\.webm/)
90 const torrent = await webtorrentAdd(magnetUri, true)
91 expect(torrent.files).to.be.an('array')
92 expect(torrent.files.length).to.equal(1)
93 expect(torrent.files[0].path).match(/\.webm$/)
97 it('Should transcode video on server 2', async function () {
101 name: 'my super name for server 2',
102 description: 'my super description for server 2',
103 fixture: 'video_short.webm'
105 await servers[1].videos.upload({ attributes })
107 await waitJobs(servers)
109 for (const server of servers) {
110 const { data } = await server.videos.list()
112 const video = data.find(v => v.name === attributes.name)
113 const videoDetails = await server.videos.get({ id: video.id })
115 expect(videoDetails.files).to.have.lengthOf(5)
117 const magnetUri = videoDetails.files[0].magnetUri
118 expect(magnetUri).to.match(/\.mp4/)
120 const torrent = await webtorrentAdd(magnetUri, true)
121 expect(torrent.files).to.be.an('array')
122 expect(torrent.files.length).to.equal(1)
123 expect(torrent.files[0].path).match(/\.mp4$/)
127 it('Should wait for transcoding before publishing the video', async function () {
128 this.timeout(160_000)
131 // Upload the video, but wait transcoding
133 name: 'waiting video',
134 fixture: 'video_short1.webm',
135 waitTranscoding: true
137 const { uuid } = await servers[1].videos.upload({ attributes })
140 // Should be in transcode state
141 const body = await servers[1].videos.get({ id: videoId })
142 expect(body.name).to.equal('waiting video')
143 expect(body.state.id).to.equal(VideoState.TO_TRANSCODE)
144 expect(body.state.label).to.equal('To transcode')
145 expect(body.waitTranscoding).to.be.true
148 // Should have my video
149 const { data } = await servers[1].videos.listMyVideos()
150 const videoToFindInMine = data.find(v => v.name === attributes.name)
151 expect(videoToFindInMine).not.to.be.undefined
152 expect(videoToFindInMine.state.id).to.equal(VideoState.TO_TRANSCODE)
153 expect(videoToFindInMine.state.label).to.equal('To transcode')
154 expect(videoToFindInMine.waitTranscoding).to.be.true
158 // Should not list this video
159 const { data } = await servers[1].videos.list()
160 const videoToFindInList = data.find(v => v.name === attributes.name)
161 expect(videoToFindInList).to.be.undefined
164 // Server 1 should not have the video yet
165 await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
168 await waitJobs(servers)
170 for (const server of servers) {
171 const { data } = await server.videos.list()
172 const videoToFind = data.find(v => v.name === 'waiting video')
173 expect(videoToFind).not.to.be.undefined
175 const videoDetails = await server.videos.get({ id: videoToFind.id })
177 expect(videoDetails.state.id).to.equal(VideoState.PUBLISHED)
178 expect(videoDetails.state.label).to.equal('Published')
179 expect(videoDetails.waitTranscoding).to.be.true
183 it('Should accept and transcode additional extensions', async function () {
184 this.timeout(300_000)
186 for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) {
192 await servers[1].videos.upload({ attributes })
194 await waitJobs(servers)
196 for (const server of servers) {
197 const { data } = await server.videos.list()
199 const video = data.find(v => v.name === attributes.name)
200 const videoDetails = await server.videos.get({ id: video.id })
201 expect(videoDetails.files).to.have.lengthOf(5)
203 const magnetUri = videoDetails.files[0].magnetUri
204 expect(magnetUri).to.contain('.mp4')
209 it('Should transcode a 4k video', async function () {
210 this.timeout(200_000)
214 fixture: 'video_short_4k.mp4'
217 const { uuid } = await servers[1].videos.upload({ attributes })
220 await waitJobs(servers)
222 const resolutions = [ 144, 240, 360, 480, 720, 1080, 1440, 2160 ]
224 for (const server of servers) {
225 const videoDetails = await server.videos.get({ id: video4k })
226 expect(videoDetails.files).to.have.lengthOf(resolutions.length)
228 for (const r of resolutions) {
229 expect(videoDetails.files.find(f => f.resolution.id === r)).to.not.be.undefined
230 expect(videoDetails.streamingPlaylists[0].files.find(f => f.resolution.id === r)).to.not.be.undefined
236 describe('Audio transcoding', function () {
238 it('Should transcode high bit rate mp3 to proper bit rate', async function () {
243 fixture: 'video_short_mp3_256k.mp4'
245 await servers[1].videos.upload({ attributes })
247 await waitJobs(servers)
249 for (const server of servers) {
250 const { data } = await server.videos.list()
252 const video = data.find(v => v.name === attributes.name)
253 const videoDetails = await server.videos.get({ id: video.id })
255 expect(videoDetails.files).to.have.lengthOf(5)
257 const file = videoDetails.files.find(f => f.resolution.id === 240)
258 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
259 const probe = await getAudioStream(path)
261 if (probe.audioStream) {
262 expect(probe.audioStream['codec_name']).to.be.equal('aac')
263 expect(probe.audioStream['bit_rate']).to.be.at.most(384 * 8000)
265 this.fail('Could not retrieve the audio stream on ' + probe.absolutePath)
270 it('Should transcode video with no audio and have no audio itself', async function () {
275 fixture: 'video_short_no_audio.mp4'
277 await servers[1].videos.upload({ attributes })
279 await waitJobs(servers)
281 for (const server of servers) {
282 const { data } = await server.videos.list()
284 const video = data.find(v => v.name === attributes.name)
285 const videoDetails = await server.videos.get({ id: video.id })
287 const file = videoDetails.files.find(f => f.resolution.id === 240)
288 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
290 const probe = await getAudioStream(path)
291 expect(probe).to.not.have.property('audioStream')
295 it('Should leave the audio untouched, but properly transcode the video', async function () {
299 name: 'untouched_audio',
300 fixture: 'video_short.mp4'
302 await servers[1].videos.upload({ attributes })
304 await waitJobs(servers)
306 for (const server of servers) {
307 const { data } = await server.videos.list()
309 const video = data.find(v => v.name === attributes.name)
310 const videoDetails = await server.videos.get({ id: video.id })
312 expect(videoDetails.files).to.have.lengthOf(5)
314 const fixturePath = buildAbsoluteFixturePath(attributes.fixture)
315 const fixtureVideoProbe = await getAudioStream(fixturePath)
317 const file = videoDetails.files.find(f => f.resolution.id === 240)
318 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
320 const videoProbe = await getAudioStream(path)
322 if (videoProbe.audioStream && fixtureVideoProbe.audioStream) {
323 const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ]
324 expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit))
326 this.fail('Could not retrieve the audio stream on ' + videoProbe.absolutePath)
332 describe('Audio upload', function () {
334 function runSuite (mode: 'legacy' | 'resumable') {
336 before(async function () {
337 await servers[1].config.updateCustomSubConfig({
340 hls: { enabled: true },
341 webtorrent: { enabled: true },
358 it('Should merge an audio file with the preview file', async function () {
361 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
362 await servers[1].videos.upload({ attributes, mode })
364 await waitJobs(servers)
366 for (const server of servers) {
367 const { data } = await server.videos.list()
369 const video = data.find(v => v.name === 'audio_with_preview')
370 const videoDetails = await server.videos.get({ id: video.id })
372 expect(videoDetails.files).to.have.lengthOf(1)
374 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
375 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 })
377 const magnetUri = videoDetails.files[0].magnetUri
378 expect(magnetUri).to.contain('.mp4')
382 it('Should upload an audio file and choose a default background image', async function () {
385 const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
386 await servers[1].videos.upload({ attributes, mode })
388 await waitJobs(servers)
390 for (const server of servers) {
391 const { data } = await server.videos.list()
393 const video = data.find(v => v.name === 'audio_without_preview')
394 const videoDetails = await server.videos.get({ id: video.id })
396 expect(videoDetails.files).to.have.lengthOf(1)
398 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
399 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, expectedStatus: HttpStatusCode.OK_200 })
401 const magnetUri = videoDetails.files[0].magnetUri
402 expect(magnetUri).to.contain('.mp4')
406 it('Should upload an audio file and create an audio version only', async function () {
409 await servers[1].config.updateCustomSubConfig({
412 hls: { enabled: true },
413 webtorrent: { enabled: true },
424 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
425 const { id } = await servers[1].videos.upload({ attributes, mode })
427 await waitJobs(servers)
429 for (const server of servers) {
430 const videoDetails = await server.videos.get({ id })
432 for (const files of [ videoDetails.files, videoDetails.streamingPlaylists[0].files ]) {
433 expect(files).to.have.lengthOf(2)
434 expect(files.find(f => f.resolution.id === 0)).to.not.be.undefined
438 await updateConfigForTranscoding(servers[1])
442 describe('Legacy upload', function () {
446 describe('Resumable upload', function () {
447 runSuite('resumable')
451 describe('Framerate', function () {
453 it('Should transcode a 60 FPS video', async function () {
457 name: 'my super 30fps name for server 2',
458 description: 'my super 30fps description for server 2',
459 fixture: '60fps_720p_small.mp4'
461 await servers[1].videos.upload({ attributes })
463 await waitJobs(servers)
465 for (const server of servers) {
466 const { data } = await server.videos.list()
468 const video = data.find(v => v.name === attributes.name)
469 const videoDetails = await server.videos.get({ id: video.id })
471 expect(videoDetails.files).to.have.lengthOf(5)
472 expect(videoDetails.files[0].fps).to.be.above(58).and.below(62)
473 expect(videoDetails.files[1].fps).to.be.below(31)
474 expect(videoDetails.files[2].fps).to.be.below(31)
475 expect(videoDetails.files[3].fps).to.be.below(31)
476 expect(videoDetails.files[4].fps).to.be.below(31)
478 for (const resolution of [ 144, 240, 360, 480 ]) {
479 const file = videoDetails.files.find(f => f.resolution.id === resolution)
480 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
481 const fps = await getVideoFileFPS(path)
483 expect(fps).to.be.below(31)
486 const file = videoDetails.files.find(f => f.resolution.id === 720)
487 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
488 const fps = await getVideoFileFPS(path)
490 expect(fps).to.be.above(58).and.below(62)
494 it('Should downscale to the closest divisor standard framerate', async function () {
495 this.timeout(200_000)
497 let tempFixturePath: string
500 tempFixturePath = await generateVideoWithFramerate(59)
502 const fps = await getVideoFileFPS(tempFixturePath)
503 expect(fps).to.be.equal(59)
508 description: '59fps video',
509 fixture: tempFixturePath
512 await servers[1].videos.upload({ attributes })
514 await waitJobs(servers)
516 for (const server of servers) {
517 const { data } = await server.videos.list()
519 const { id } = data.find(v => v.name === attributes.name)
520 const video = await server.videos.get({ id })
523 const file = video.files.find(f => f.resolution.id === 240)
524 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
525 const fps = await getVideoFileFPS(path)
526 expect(fps).to.be.equal(25)
530 const file = video.files.find(f => f.resolution.id === 720)
531 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
532 const fps = await getVideoFileFPS(path)
533 expect(fps).to.be.equal(59)
539 describe('Bitrate control', function () {
541 it('Should respect maximum bitrate values', async function () {
542 this.timeout(160_000)
544 const tempFixturePath = await generateHighBitrateVideo()
547 name: 'high bitrate video',
548 description: 'high bitrate video',
549 fixture: tempFixturePath
552 await servers[1].videos.upload({ attributes })
554 await waitJobs(servers)
556 for (const server of servers) {
557 const { data } = await server.videos.list()
559 const { id } = data.find(v => v.name === attributes.name)
560 const video = await server.videos.get({ id })
562 for (const resolution of [ 240, 360, 480, 720, 1080 ]) {
563 const file = video.files.find(f => f.resolution.id === resolution)
564 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
566 const bitrate = await getVideoFileBitrate(path)
567 const fps = await getVideoFileFPS(path)
568 const dataResolution = await getVideoFileResolution(path)
570 expect(resolution).to.equal(resolution)
572 const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
573 expect(bitrate).to.be.below(maxBitrate)
578 it('Should not transcode to an higher bitrate than the original file but above our low limit', async function () {
579 this.timeout(160_000)
594 webtorrent: { enabled: true },
595 hls: { enabled: true }
598 await servers[1].config.updateCustomSubConfig({ newConfig })
602 fixture: 'low-bitrate.mp4'
605 const { id } = await servers[1].videos.upload({ attributes })
607 await waitJobs(servers)
609 const video = await servers[1].videos.get({ id })
611 const resolutions = [ 240, 360, 480, 720, 1080 ]
612 for (const r of resolutions) {
613 const file = video.files.find(f => f.resolution.id === r)
615 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
616 const bitrate = await getVideoFileBitrate(path)
618 const inputBitrate = 60_000
619 const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r })
620 let belowValue = Math.max(inputBitrate, limit)
621 belowValue += belowValue * 0.20 // Apply 20% margin because bitrate control is not very precise
623 expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue)
628 describe('FFprobe', function () {
630 it('Should provide valid ffprobe data', async function () {
631 this.timeout(160_000)
633 const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid
634 await waitJobs(servers)
637 const video = await servers[1].videos.get({ id: videoUUID })
638 const file = video.files.find(f => f.resolution.id === 240)
639 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
640 const metadata = await getMetadataFromFile(path)
642 // expected format properties
649 expect(metadata.format).to.have.nested.property(p)
652 // expected stream properties
658 'display_aspect_ratio',
662 expect(metadata.streams[0]).to.have.nested.property(p)
665 expect(metadata).to.not.have.nested.property('format.filename')
668 for (const server of servers) {
669 const videoDetails = await server.videos.get({ id: videoUUID })
671 const videoFiles = videoDetails.files
672 .concat(videoDetails.streamingPlaylists[0].files)
673 expect(videoFiles).to.have.lengthOf(10)
675 for (const file of videoFiles) {
676 expect(file.metadata).to.be.undefined
677 expect(file.metadataUrl).to.exist
678 expect(file.metadataUrl).to.contain(servers[1].url)
679 expect(file.metadataUrl).to.contain(videoUUID)
681 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
682 expect(metadata).to.have.nested.property('format.size')
687 it('Should correctly detect if quick transcode is possible', async function () {
690 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true
691 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false
695 describe('Transcoding job queue', function () {
697 it('Should have the appropriate priorities for transcoding jobs', async function () {
698 const body = await servers[1].jobs.list({
702 jobType: 'video-transcoding'
705 const jobs = body.data
706 const transcodingJobs = jobs.filter(j => j.data.videoUUID === video4k)
708 expect(transcodingJobs).to.have.lengthOf(16)
710 const hlsJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-hls')
711 const webtorrentJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-webtorrent')
712 const optimizeJobs = transcodingJobs.filter(j => j.data.type === 'optimize-to-webtorrent')
714 expect(hlsJobs).to.have.lengthOf(8)
715 expect(webtorrentJobs).to.have.lengthOf(7)
716 expect(optimizeJobs).to.have.lengthOf(1)
718 for (const j of optimizeJobs.concat(hlsJobs.concat(webtorrentJobs))) {
719 expect(j.priority).to.be.greaterThan(100)
720 expect(j.priority).to.be.lessThan(150)
725 after(async function () {
726 await cleanupTests(servers)