diff options
author | Chocobozzz <me@florianbigard.com> | 2020-11-24 14:08:23 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-11-25 10:07:51 +0100 |
commit | 5a547f69d5dc5867e253f7721513479c754b4f15 (patch) | |
tree | 5ccad0e07d04e24d7a4c0b624a46d3b5a93ebce5 /server/helpers/ffmpeg-utils.ts | |
parent | 9252a33d115bba85adcfbc18ab3725924642871c (diff) | |
download | PeerTube-5a547f69d5dc5867e253f7721513479c754b4f15.tar.gz PeerTube-5a547f69d5dc5867e253f7721513479c754b4f15.tar.zst PeerTube-5a547f69d5dc5867e253f7721513479c754b4f15.zip |
Support encoding profiles
Diffstat (limited to 'server/helpers/ffmpeg-utils.ts')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 223 |
1 files changed, 161 insertions, 62 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index e297108df..712ec757e 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -1,10 +1,10 @@ | |||
1 | import * as ffmpeg from 'fluent-ffmpeg' | 1 | import * as ffmpeg from 'fluent-ffmpeg' |
2 | import { readFile, remove, writeFile } from 'fs-extra' | 2 | import { readFile, remove, writeFile } from 'fs-extra' |
3 | import { dirname, join } from 'path' | 3 | import { dirname, join } from 'path' |
4 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' | 4 | import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_ENCODERS, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' |
5 | import { VideoResolution } from '../../shared/models/videos' | ||
5 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | 6 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' |
6 | import { CONFIG } from '../initializers/config' | 7 | import { CONFIG } from '../initializers/config' |
7 | import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_ENCODERS, VIDEO_TRANSCODING_FPS } from '../initializers/constants' | ||
8 | import { getAudioStream, getClosestFramerateStandard, getVideoFileFPS } from './ffprobe-utils' | 8 | import { getAudioStream, getClosestFramerateStandard, getVideoFileFPS } from './ffprobe-utils' |
9 | import { processImage } from './image-utils' | 9 | import { processImage } from './image-utils' |
10 | import { logger } from './logger' | 10 | import { logger } from './logger' |
@@ -19,11 +19,13 @@ export type EncoderOptionsBuilder = (params: { | |||
19 | input: string | 19 | input: string |
20 | resolution: VideoResolution | 20 | resolution: VideoResolution |
21 | fps?: number | 21 | fps?: number |
22 | streamNum?: number | ||
22 | }) => Promise<EncoderOptions> | EncoderOptions | 23 | }) => Promise<EncoderOptions> | EncoderOptions |
23 | 24 | ||
24 | // Options types | 25 | // Options types |
25 | 26 | ||
26 | export interface EncoderOptions { | 27 | export interface EncoderOptions { |
28 | copy?: boolean | ||
27 | outputOptions: string[] | 29 | outputOptions: string[] |
28 | } | 30 | } |
29 | 31 | ||
@@ -37,7 +39,7 @@ export interface EncoderProfile <T> { | |||
37 | 39 | ||
38 | export type AvailableEncoders = { | 40 | export type AvailableEncoders = { |
39 | [ id in 'live' | 'vod' ]: { | 41 | [ id in 'live' | 'vod' ]: { |
40 | [ encoder in 'libx264' | 'aac' | 'libfdkAAC' ]: EncoderProfile<EncoderOptionsBuilder> | 42 | [ encoder in 'libx264' | 'aac' | 'libfdk_aac' ]?: EncoderProfile<EncoderOptionsBuilder> |
41 | } | 43 | } |
42 | } | 44 | } |
43 | 45 | ||
@@ -197,8 +199,20 @@ async function transcode (options: TranscodeOptions) { | |||
197 | // Live muxing/transcoding functions | 199 | // Live muxing/transcoding functions |
198 | // --------------------------------------------------------------------------- | 200 | // --------------------------------------------------------------------------- |
199 | 201 | ||
200 | function getLiveTranscodingCommand (rtmpUrl: string, outPath: string, resolutions: number[], fps: number, deleteSegments: boolean) { | 202 | async function getLiveTranscodingCommand (options: { |
201 | const command = getFFmpeg(rtmpUrl) | 203 | rtmpUrl: string |
204 | outPath: string | ||
205 | resolutions: number[] | ||
206 | fps: number | ||
207 | deleteSegments: boolean | ||
208 | |||
209 | availableEncoders: AvailableEncoders | ||
210 | profile: string | ||
211 | }) { | ||
212 | const { rtmpUrl, outPath, resolutions, fps, deleteSegments, availableEncoders, profile } = options | ||
213 | const input = rtmpUrl | ||
214 | |||
215 | const command = getFFmpeg(input) | ||
202 | command.inputOption('-fflags nobuffer') | 216 | command.inputOption('-fflags nobuffer') |
203 | 217 | ||
204 | const varStreamMap: string[] = [] | 218 | const varStreamMap: string[] = [] |
@@ -219,19 +233,43 @@ function getLiveTranscodingCommand (rtmpUrl: string, outPath: string, resolution | |||
219 | })) | 233 | })) |
220 | ]) | 234 | ]) |
221 | 235 | ||
222 | addEncoderDefaultParams(command, 'libx264', fps) | ||
223 | |||
224 | command.outputOption('-preset superfast') | 236 | command.outputOption('-preset superfast') |
225 | 237 | ||
226 | for (let i = 0; i < resolutions.length; i++) { | 238 | for (let i = 0; i < resolutions.length; i++) { |
227 | const resolution = resolutions[i] | 239 | const resolution = resolutions[i] |
240 | const baseEncoderBuilderParams = { input, availableEncoders, profile, fps, resolution, streamNum: i, videoType: 'live' as 'live' } | ||
241 | |||
242 | { | ||
243 | const builderResult = await getEncoderBuilderResult(Object.assign({}, baseEncoderBuilderParams, { streamType: 'VIDEO' })) | ||
244 | if (!builderResult) { | ||
245 | throw new Error('No available live video encoder found') | ||
246 | } | ||
247 | |||
248 | command.outputOption(`-map [vout${resolution}]`) | ||
249 | |||
250 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) | ||
251 | |||
252 | logger.debug('Apply ffmpeg live video params from %s.', builderResult.encoder, builderResult) | ||
228 | 253 | ||
229 | command.outputOption(`-map [vout${resolution}]`) | 254 | command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) |
230 | command.outputOption(`-c:v:${i} libx264`) | 255 | command.addOutputOptions(builderResult.result.outputOptions) |
231 | command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)}`) | 256 | } |
257 | |||
258 | { | ||
259 | const builderResult = await getEncoderBuilderResult(Object.assign({}, baseEncoderBuilderParams, { streamType: 'AUDIO' })) | ||
260 | if (!builderResult) { | ||
261 | throw new Error('No available live audio encoder found') | ||
262 | } | ||
232 | 263 | ||
233 | command.outputOption(`-map a:0`) | 264 | command.outputOption('-map a:0') |
234 | command.outputOption(`-c:a:${i} aac`) | 265 | |
266 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) | ||
267 | |||
268 | logger.debug('Apply ffmpeg live audio params from %s.', builderResult.encoder, builderResult) | ||
269 | |||
270 | command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) | ||
271 | command.addOutputOptions(builderResult.result.outputOptions) | ||
272 | } | ||
235 | 273 | ||
236 | varStreamMap.push(`v:${i},a:${i}`) | 274 | varStreamMap.push(`v:${i},a:${i}`) |
237 | } | 275 | } |
@@ -282,11 +320,20 @@ async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: s | |||
282 | return runCommand(command, cleaner) | 320 | return runCommand(command, cleaner) |
283 | } | 321 | } |
284 | 322 | ||
323 | function buildStreamSuffix (base: string, streamNum?: number) { | ||
324 | if (streamNum !== undefined) { | ||
325 | return `${base}:${streamNum}` | ||
326 | } | ||
327 | |||
328 | return base | ||
329 | } | ||
330 | |||
285 | // --------------------------------------------------------------------------- | 331 | // --------------------------------------------------------------------------- |
286 | 332 | ||
287 | export { | 333 | export { |
288 | getLiveTranscodingCommand, | 334 | getLiveTranscodingCommand, |
289 | getLiveMuxingCommand, | 335 | getLiveMuxingCommand, |
336 | buildStreamSuffix, | ||
290 | convertWebPToJPG, | 337 | convertWebPToJPG, |
291 | processGIF, | 338 | processGIF, |
292 | generateImageFromVideoFile, | 339 | generateImageFromVideoFile, |
@@ -302,19 +349,35 @@ export { | |||
302 | // Default options | 349 | // Default options |
303 | // --------------------------------------------------------------------------- | 350 | // --------------------------------------------------------------------------- |
304 | 351 | ||
305 | function addEncoderDefaultParams (command: ffmpeg.FfmpegCommand, encoder: 'libx264' | string, fps?: number) { | 352 | function addDefaultEncoderParams (options: { |
306 | if (encoder !== 'libx264') return | 353 | command: ffmpeg.FfmpegCommand |
307 | 354 | encoder: 'libx264' | string | |
308 | command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution | 355 | streamNum?: number |
309 | .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it | 356 | fps?: number |
310 | .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 | 357 | }) { |
311 | .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) | 358 | const { command, encoder, fps, streamNum } = options |
312 | .outputOption('-map_metadata -1') // strip all metadata | 359 | |
313 | .outputOption('-max_muxing_queue_size 1024') // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 | 360 | if (encoder === 'libx264') { |
314 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | 361 | // 3.1 is the minimal resource allocation for our highest supported resolution |
315 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | 362 | command.outputOption('-level 3.1') |
316 | // https://superuser.com/a/908325 | 363 | // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it |
317 | .outputOption('-g ' + (fps * 2)) | 364 | .outputOption('-b_strategy 1') |
365 | // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 | ||
366 | .outputOption('-bf 16') | ||
367 | // allows import of source material with incompatible pixel formats (e.g. MJPEG video) | ||
368 | .outputOption(buildStreamSuffix('-pix_fmt', streamNum) + ' yuv420p') | ||
369 | // strip all metadata | ||
370 | .outputOption('-map_metadata -1') | ||
371 | // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 | ||
372 | .outputOption(buildStreamSuffix('-max_muxing_queue_size', streamNum) + ' 1024') | ||
373 | |||
374 | if (fps) { | ||
375 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | ||
376 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | ||
377 | // https://superuser.com/a/908325 | ||
378 | command.outputOption('-g ' + (fps * 2)) | ||
379 | } | ||
380 | } | ||
318 | } | 381 | } |
319 | 382 | ||
320 | function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { | 383 | function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { |
@@ -352,17 +415,18 @@ async function buildx264VODCommand (command: ffmpeg.FfmpegCommand, options: Tran | |||
352 | 415 | ||
353 | if (options.resolution !== undefined) { | 416 | if (options.resolution !== undefined) { |
354 | // '?x720' or '720x?' for example | 417 | // '?x720' or '720x?' for example |
355 | const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` | 418 | const size = options.isPortraitMode === true |
419 | ? `${options.resolution}x?` | ||
420 | : `?x${options.resolution}` | ||
421 | |||
356 | command = command.size(size) | 422 | command = command.size(size) |
357 | } | 423 | } |
358 | 424 | ||
359 | if (fps) { | 425 | // Hard FPS limits |
360 | // Hard FPS limits | 426 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD') |
361 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD') | 427 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN |
362 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN | ||
363 | 428 | ||
364 | command = command.withFPS(fps) | 429 | command = command.withFPS(fps) |
365 | } | ||
366 | 430 | ||
367 | return command | 431 | return command |
368 | } | 432 | } |
@@ -445,6 +509,49 @@ function getHLSVideoPath (options: HLSTranscodeOptions) { | |||
445 | // Transcoding presets | 509 | // Transcoding presets |
446 | // --------------------------------------------------------------------------- | 510 | // --------------------------------------------------------------------------- |
447 | 511 | ||
512 | async function getEncoderBuilderResult (options: { | ||
513 | streamType: string | ||
514 | input: string | ||
515 | |||
516 | availableEncoders: AvailableEncoders | ||
517 | profile: string | ||
518 | |||
519 | videoType: 'vod' | 'live' | ||
520 | |||
521 | resolution: number | ||
522 | fps?: number | ||
523 | streamNum?: number | ||
524 | }) { | ||
525 | const { availableEncoders, input, profile, resolution, streamType, fps, streamNum, videoType } = options | ||
526 | |||
527 | const encodersToTry: string[] = VIDEO_TRANSCODING_ENCODERS[streamType] | ||
528 | |||
529 | for (const encoder of encodersToTry) { | ||
530 | if (!(await checkFFmpegEncoders()).get(encoder) || !availableEncoders[videoType][encoder]) continue | ||
531 | |||
532 | const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = availableEncoders[videoType][encoder] | ||
533 | let builder = builderProfiles[profile] | ||
534 | |||
535 | if (!builder) { | ||
536 | logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder) | ||
537 | builder = builderProfiles.default | ||
538 | } | ||
539 | |||
540 | const result = await builder({ input, resolution: resolution, fps, streamNum }) | ||
541 | |||
542 | return { | ||
543 | result, | ||
544 | |||
545 | // If we don't have output options, then copy the input stream | ||
546 | encoder: result.copy === true | ||
547 | ? 'copy' | ||
548 | : encoder | ||
549 | } | ||
550 | } | ||
551 | |||
552 | return null | ||
553 | } | ||
554 | |||
448 | async function presetVideo ( | 555 | async function presetVideo ( |
449 | command: ffmpeg.FfmpegCommand, | 556 | command: ffmpeg.FfmpegCommand, |
450 | input: string, | 557 | input: string, |
@@ -459,49 +566,41 @@ async function presetVideo ( | |||
459 | const parsedAudio = await getAudioStream(input) | 566 | const parsedAudio = await getAudioStream(input) |
460 | 567 | ||
461 | let streamsToProcess = [ 'AUDIO', 'VIDEO' ] | 568 | let streamsToProcess = [ 'AUDIO', 'VIDEO' ] |
462 | const streamsFound = { | ||
463 | AUDIO: '', | ||
464 | VIDEO: '' | ||
465 | } | ||
466 | 569 | ||
467 | if (!parsedAudio.audioStream) { | 570 | if (!parsedAudio.audioStream) { |
468 | localCommand = localCommand.noAudio() | 571 | localCommand = localCommand.noAudio() |
469 | streamsToProcess = [ 'VIDEO' ] | 572 | streamsToProcess = [ 'VIDEO' ] |
470 | } | 573 | } |
471 | 574 | ||
472 | for (const stream of streamsToProcess) { | 575 | for (const streamType of streamsToProcess) { |
473 | const encodersToTry: string[] = VIDEO_TRANSCODING_ENCODERS[stream] | 576 | const { profile, resolution, availableEncoders } = transcodeOptions |
474 | 577 | ||
475 | for (const encoder of encodersToTry) { | 578 | const builderResult = await getEncoderBuilderResult({ |
476 | if (!(await checkFFmpegEncoders()).get(encoder)) continue | 579 | streamType, |
477 | 580 | input, | |
478 | const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = transcodeOptions.availableEncoders.vod[encoder] | 581 | resolution, |
479 | let builder = builderProfiles[transcodeOptions.profile] | 582 | availableEncoders, |
480 | 583 | profile, | |
481 | if (!builder) { | 584 | fps, |
482 | logger.debug('Profile %s for encoder %s not available. Fallback to default.', transcodeOptions.profile, encoder) | 585 | videoType: 'vod' as 'vod' |
483 | builder = builderProfiles.default | 586 | }) |
484 | } | ||
485 | |||
486 | const builderResult = await builder({ input, resolution: transcodeOptions.resolution, fps }) | ||
487 | |||
488 | logger.debug('Apply ffmpeg params from %s.', encoder, builderResult) | ||
489 | 587 | ||
490 | localCommand.outputOptions(builderResult.outputOptions) | 588 | if (!builderResult) { |
589 | throw new Error('No available encoder found for stream ' + streamType) | ||
590 | } | ||
491 | 591 | ||
492 | addEncoderDefaultParams(localCommand, encoder) | 592 | logger.debug('Apply ffmpeg params from %s.', builderResult.encoder, builderResult) |
493 | 593 | ||
494 | streamsFound[stream] = encoder | 594 | if (streamType === 'VIDEO') { |
495 | break | 595 | localCommand.videoCodec(builderResult.encoder) |
596 | } else if (streamType === 'AUDIO') { | ||
597 | localCommand.audioCodec(builderResult.encoder) | ||
496 | } | 598 | } |
497 | 599 | ||
498 | if (!streamsFound[stream]) { | 600 | command.addOutputOptions(builderResult.result.outputOptions) |
499 | throw new Error('No available encoder found ' + encodersToTry.join(', ')) | 601 | addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps }) |
500 | } | ||
501 | } | 602 | } |
502 | 603 | ||
503 | localCommand.videoCodec(streamsFound.VIDEO) | ||
504 | |||
505 | return localCommand | 604 | return localCommand |
506 | } | 605 | } |
507 | 606 | ||