]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/tests/api/videos/video-transcoder.ts
Merge branch 'release/4.0.0' into develop
[github/Chocobozzz/PeerTube.git] / server / tests / api / videos / video-transcoder.ts
1 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3 import 'mocha'
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'
11 import {
12 cleanupTests,
13 createMultipleServers,
14 doubleFollow,
15 makeGetRequest,
16 PeerTubeServer,
17 setAccessTokensToServers,
18 waitJobs,
19 webtorrentAdd
20 } from '@shared/server-commands'
21
22 const expect = chai.expect
23
24 function updateConfigForTranscoding (server: PeerTubeServer) {
25 return server.config.updateCustomSubConfig({
26 newConfig: {
27 transcoding: {
28 enabled: true,
29 allowAdditionalExtensions: true,
30 allowAudioFiles: true,
31 hls: { enabled: true },
32 webtorrent: { enabled: true },
33 resolutions: {
34 '0p': false,
35 '144p': true,
36 '240p': true,
37 '360p': true,
38 '480p': true,
39 '720p': true,
40 '1080p': true,
41 '1440p': true,
42 '2160p': true
43 }
44 }
45 }
46 })
47 }
48
49 describe('Test video transcoding', function () {
50 let servers: PeerTubeServer[] = []
51 let video4k: string
52
53 before(async function () {
54 this.timeout(30_000)
55
56 // Run servers
57 servers = await createMultipleServers(2)
58
59 await setAccessTokensToServers(servers)
60
61 await doubleFollow(servers[0], servers[1])
62
63 await updateConfigForTranscoding(servers[1])
64 })
65
66 describe('Basic transcoding (or not)', function () {
67
68 it('Should not transcode video on server 1', async function () {
69 this.timeout(60_000)
70
71 const attributes = {
72 name: 'my super name for server 1',
73 description: 'my super description for server 1',
74 fixture: 'video_short.webm'
75 }
76 await servers[0].videos.upload({ attributes })
77
78 await waitJobs(servers)
79
80 for (const server of servers) {
81 const { data } = await server.videos.list()
82 const video = data[0]
83
84 const videoDetails = await server.videos.get({ id: video.id })
85 expect(videoDetails.files).to.have.lengthOf(1)
86
87 const magnetUri = videoDetails.files[0].magnetUri
88 expect(magnetUri).to.match(/\.webm/)
89
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$/)
94 }
95 })
96
97 it('Should transcode video on server 2', async function () {
98 this.timeout(120_000)
99
100 const attributes = {
101 name: 'my super name for server 2',
102 description: 'my super description for server 2',
103 fixture: 'video_short.webm'
104 }
105 await servers[1].videos.upload({ attributes })
106
107 await waitJobs(servers)
108
109 for (const server of servers) {
110 const { data } = await server.videos.list()
111
112 const video = data.find(v => v.name === attributes.name)
113 const videoDetails = await server.videos.get({ id: video.id })
114
115 expect(videoDetails.files).to.have.lengthOf(5)
116
117 const magnetUri = videoDetails.files[0].magnetUri
118 expect(magnetUri).to.match(/\.mp4/)
119
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$/)
124 }
125 })
126
127 it('Should wait for transcoding before publishing the video', async function () {
128 this.timeout(160_000)
129
130 {
131 // Upload the video, but wait transcoding
132 const attributes = {
133 name: 'waiting video',
134 fixture: 'video_short1.webm',
135 waitTranscoding: true
136 }
137 const { uuid } = await servers[1].videos.upload({ attributes })
138 const videoId = uuid
139
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
146
147 {
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
155 }
156
157 {
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
162 }
163
164 // Server 1 should not have the video yet
165 await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
166 }
167
168 await waitJobs(servers)
169
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
174
175 const videoDetails = await server.videos.get({ id: videoToFind.id })
176
177 expect(videoDetails.state.id).to.equal(VideoState.PUBLISHED)
178 expect(videoDetails.state.label).to.equal('Published')
179 expect(videoDetails.waitTranscoding).to.be.true
180 }
181 })
182
183 it('Should accept and transcode additional extensions', async function () {
184 this.timeout(300_000)
185
186 for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) {
187 const attributes = {
188 name: fixture,
189 fixture
190 }
191
192 await servers[1].videos.upload({ attributes })
193
194 await waitJobs(servers)
195
196 for (const server of servers) {
197 const { data } = await server.videos.list()
198
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)
202
203 const magnetUri = videoDetails.files[0].magnetUri
204 expect(magnetUri).to.contain('.mp4')
205 }
206 }
207 })
208
209 it('Should transcode a 4k video', async function () {
210 this.timeout(200_000)
211
212 const attributes = {
213 name: '4k video',
214 fixture: 'video_short_4k.mp4'
215 }
216
217 const { uuid } = await servers[1].videos.upload({ attributes })
218 video4k = uuid
219
220 await waitJobs(servers)
221
222 const resolutions = [ 144, 240, 360, 480, 720, 1080, 1440, 2160 ]
223
224 for (const server of servers) {
225 const videoDetails = await server.videos.get({ id: video4k })
226 expect(videoDetails.files).to.have.lengthOf(resolutions.length)
227
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
231 }
232 }
233 })
234 })
235
236 describe('Audio transcoding', function () {
237
238 it('Should transcode high bit rate mp3 to proper bit rate', async function () {
239 this.timeout(60_000)
240
241 const attributes = {
242 name: 'mp3_256k',
243 fixture: 'video_short_mp3_256k.mp4'
244 }
245 await servers[1].videos.upload({ attributes })
246
247 await waitJobs(servers)
248
249 for (const server of servers) {
250 const { data } = await server.videos.list()
251
252 const video = data.find(v => v.name === attributes.name)
253 const videoDetails = await server.videos.get({ id: video.id })
254
255 expect(videoDetails.files).to.have.lengthOf(5)
256
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)
260
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)
264 } else {
265 this.fail('Could not retrieve the audio stream on ' + probe.absolutePath)
266 }
267 }
268 })
269
270 it('Should transcode video with no audio and have no audio itself', async function () {
271 this.timeout(60_000)
272
273 const attributes = {
274 name: 'no_audio',
275 fixture: 'video_short_no_audio.mp4'
276 }
277 await servers[1].videos.upload({ attributes })
278
279 await waitJobs(servers)
280
281 for (const server of servers) {
282 const { data } = await server.videos.list()
283
284 const video = data.find(v => v.name === attributes.name)
285 const videoDetails = await server.videos.get({ id: video.id })
286
287 const file = videoDetails.files.find(f => f.resolution.id === 240)
288 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
289
290 const probe = await getAudioStream(path)
291 expect(probe).to.not.have.property('audioStream')
292 }
293 })
294
295 it('Should leave the audio untouched, but properly transcode the video', async function () {
296 this.timeout(60_000)
297
298 const attributes = {
299 name: 'untouched_audio',
300 fixture: 'video_short.mp4'
301 }
302 await servers[1].videos.upload({ attributes })
303
304 await waitJobs(servers)
305
306 for (const server of servers) {
307 const { data } = await server.videos.list()
308
309 const video = data.find(v => v.name === attributes.name)
310 const videoDetails = await server.videos.get({ id: video.id })
311
312 expect(videoDetails.files).to.have.lengthOf(5)
313
314 const fixturePath = buildAbsoluteFixturePath(attributes.fixture)
315 const fixtureVideoProbe = await getAudioStream(fixturePath)
316
317 const file = videoDetails.files.find(f => f.resolution.id === 240)
318 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
319
320 const videoProbe = await getAudioStream(path)
321
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))
325 } else {
326 this.fail('Could not retrieve the audio stream on ' + videoProbe.absolutePath)
327 }
328 }
329 })
330 })
331
332 describe('Audio upload', function () {
333
334 function runSuite (mode: 'legacy' | 'resumable') {
335
336 before(async function () {
337 await servers[1].config.updateCustomSubConfig({
338 newConfig: {
339 transcoding: {
340 hls: { enabled: true },
341 webtorrent: { enabled: true },
342 resolutions: {
343 '0p': false,
344 '144p': false,
345 '240p': false,
346 '360p': false,
347 '480p': false,
348 '720p': false,
349 '1080p': false,
350 '1440p': false,
351 '2160p': false
352 }
353 }
354 }
355 })
356 })
357
358 it('Should merge an audio file with the preview file', async function () {
359 this.timeout(60_000)
360
361 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
362 await servers[1].videos.upload({ attributes, mode })
363
364 await waitJobs(servers)
365
366 for (const server of servers) {
367 const { data } = await server.videos.list()
368
369 const video = data.find(v => v.name === 'audio_with_preview')
370 const videoDetails = await server.videos.get({ id: video.id })
371
372 expect(videoDetails.files).to.have.lengthOf(1)
373
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 })
376
377 const magnetUri = videoDetails.files[0].magnetUri
378 expect(magnetUri).to.contain('.mp4')
379 }
380 })
381
382 it('Should upload an audio file and choose a default background image', async function () {
383 this.timeout(60_000)
384
385 const attributes = { name: 'audio_without_preview', fixture: 'sample.ogg' }
386 await servers[1].videos.upload({ attributes, mode })
387
388 await waitJobs(servers)
389
390 for (const server of servers) {
391 const { data } = await server.videos.list()
392
393 const video = data.find(v => v.name === 'audio_without_preview')
394 const videoDetails = await server.videos.get({ id: video.id })
395
396 expect(videoDetails.files).to.have.lengthOf(1)
397
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 })
400
401 const magnetUri = videoDetails.files[0].magnetUri
402 expect(magnetUri).to.contain('.mp4')
403 }
404 })
405
406 it('Should upload an audio file and create an audio version only', async function () {
407 this.timeout(60_000)
408
409 await servers[1].config.updateCustomSubConfig({
410 newConfig: {
411 transcoding: {
412 hls: { enabled: true },
413 webtorrent: { enabled: true },
414 resolutions: {
415 '0p': true,
416 '144p': false,
417 '240p': false,
418 '360p': false
419 }
420 }
421 }
422 })
423
424 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
425 const { id } = await servers[1].videos.upload({ attributes, mode })
426
427 await waitJobs(servers)
428
429 for (const server of servers) {
430 const videoDetails = await server.videos.get({ id })
431
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
435 }
436 }
437
438 await updateConfigForTranscoding(servers[1])
439 })
440 }
441
442 describe('Legacy upload', function () {
443 runSuite('legacy')
444 })
445
446 describe('Resumable upload', function () {
447 runSuite('resumable')
448 })
449 })
450
451 describe('Framerate', function () {
452
453 it('Should transcode a 60 FPS video', async function () {
454 this.timeout(60_000)
455
456 const attributes = {
457 name: 'my super 30fps name for server 2',
458 description: 'my super 30fps description for server 2',
459 fixture: '60fps_720p_small.mp4'
460 }
461 await servers[1].videos.upload({ attributes })
462
463 await waitJobs(servers)
464
465 for (const server of servers) {
466 const { data } = await server.videos.list()
467
468 const video = data.find(v => v.name === attributes.name)
469 const videoDetails = await server.videos.get({ id: video.id })
470
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)
477
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)
482
483 expect(fps).to.be.below(31)
484 }
485
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)
489
490 expect(fps).to.be.above(58).and.below(62)
491 }
492 })
493
494 it('Should downscale to the closest divisor standard framerate', async function () {
495 this.timeout(200_000)
496
497 let tempFixturePath: string
498
499 {
500 tempFixturePath = await generateVideoWithFramerate(59)
501
502 const fps = await getVideoFileFPS(tempFixturePath)
503 expect(fps).to.be.equal(59)
504 }
505
506 const attributes = {
507 name: '59fps video',
508 description: '59fps video',
509 fixture: tempFixturePath
510 }
511
512 await servers[1].videos.upload({ attributes })
513
514 await waitJobs(servers)
515
516 for (const server of servers) {
517 const { data } = await server.videos.list()
518
519 const { id } = data.find(v => v.name === attributes.name)
520 const video = await server.videos.get({ id })
521
522 {
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)
527 }
528
529 {
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)
534 }
535 }
536 })
537 })
538
539 describe('Bitrate control', function () {
540
541 it('Should respect maximum bitrate values', async function () {
542 this.timeout(160_000)
543
544 const tempFixturePath = await generateHighBitrateVideo()
545
546 const attributes = {
547 name: 'high bitrate video',
548 description: 'high bitrate video',
549 fixture: tempFixturePath
550 }
551
552 await servers[1].videos.upload({ attributes })
553
554 await waitJobs(servers)
555
556 for (const server of servers) {
557 const { data } = await server.videos.list()
558
559 const { id } = data.find(v => v.name === attributes.name)
560 const video = await server.videos.get({ id })
561
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)
565
566 const bitrate = await getVideoFileBitrate(path)
567 const fps = await getVideoFileFPS(path)
568 const dataResolution = await getVideoFileResolution(path)
569
570 expect(resolution).to.equal(resolution)
571
572 const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
573 expect(bitrate).to.be.below(maxBitrate)
574 }
575 }
576 })
577
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)
580
581 const newConfig = {
582 transcoding: {
583 enabled: true,
584 resolutions: {
585 '144p': true,
586 '240p': true,
587 '360p': true,
588 '480p': true,
589 '720p': true,
590 '1080p': true,
591 '1440p': true,
592 '2160p': true
593 },
594 webtorrent: { enabled: true },
595 hls: { enabled: true }
596 }
597 }
598 await servers[1].config.updateCustomSubConfig({ newConfig })
599
600 const attributes = {
601 name: 'low bitrate',
602 fixture: 'low-bitrate.mp4'
603 }
604
605 const { id } = await servers[1].videos.upload({ attributes })
606
607 await waitJobs(servers)
608
609 const video = await servers[1].videos.get({ id })
610
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)
614
615 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
616 const bitrate = await getVideoFileBitrate(path)
617
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
622
623 expect(bitrate, `${path} not below ${limit}`).to.be.below(belowValue)
624 }
625 })
626 })
627
628 describe('FFprobe', function () {
629
630 it('Should provide valid ffprobe data', async function () {
631 this.timeout(160_000)
632
633 const videoUUID = (await servers[1].videos.quickUpload({ name: 'ffprobe data' })).uuid
634 await waitJobs(servers)
635
636 {
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)
641
642 // expected format properties
643 for (const p of [
644 'tags.encoder',
645 'format_long_name',
646 'size',
647 'bit_rate'
648 ]) {
649 expect(metadata.format).to.have.nested.property(p)
650 }
651
652 // expected stream properties
653 for (const p of [
654 'codec_long_name',
655 'profile',
656 'width',
657 'height',
658 'display_aspect_ratio',
659 'avg_frame_rate',
660 'pix_fmt'
661 ]) {
662 expect(metadata.streams[0]).to.have.nested.property(p)
663 }
664
665 expect(metadata).to.not.have.nested.property('format.filename')
666 }
667
668 for (const server of servers) {
669 const videoDetails = await server.videos.get({ id: videoUUID })
670
671 const videoFiles = videoDetails.files
672 .concat(videoDetails.streamingPlaylists[0].files)
673 expect(videoFiles).to.have.lengthOf(10)
674
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)
680
681 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
682 expect(metadata).to.have.nested.property('format.size')
683 }
684 }
685 })
686
687 it('Should correctly detect if quick transcode is possible', async function () {
688 this.timeout(10_000)
689
690 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.mp4'))).to.be.true
691 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false
692 })
693 })
694
695 describe('Transcoding job queue', function () {
696
697 it('Should have the appropriate priorities for transcoding jobs', async function () {
698 const body = await servers[1].jobs.list({
699 start: 0,
700 count: 100,
701 sort: 'createdAt',
702 jobType: 'video-transcoding'
703 })
704
705 const jobs = body.data
706 const transcodingJobs = jobs.filter(j => j.data.videoUUID === video4k)
707
708 expect(transcodingJobs).to.have.lengthOf(16)
709
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')
713
714 expect(hlsJobs).to.have.lengthOf(8)
715 expect(webtorrentJobs).to.have.lengthOf(7)
716 expect(optimizeJobs).to.have.lengthOf(1)
717
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)
721 }
722 })
723 })
724
725 after(async function () {
726 await cleanupTests(servers)
727 })
728 })