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