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