diff options
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 346 | ||||
-rw-r--r-- | server/initializers/checker-before-init.ts | 29 | ||||
-rw-r--r-- | server/initializers/constants.ts | 12 | ||||
-rw-r--r-- | server/lib/live-manager.ts | 8 | ||||
-rw-r--r-- | server/lib/video-transcoding.ts | 114 |
5 files changed, 341 insertions, 168 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index df3926658..e297108df 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -4,11 +4,88 @@ import { dirname, join } from 'path' | |||
4 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' | 4 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' |
5 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | 5 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' |
6 | import { CONFIG } from '../initializers/config' | 6 | import { CONFIG } from '../initializers/config' |
7 | import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' | 7 | import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_ENCODERS, VIDEO_TRANSCODING_FPS } from '../initializers/constants' |
8 | import { getAudioStream, getClosestFramerateStandard, getMaxAudioBitrate, 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' |
11 | 11 | ||
12 | // --------------------------------------------------------------------------- | ||
13 | // Encoder options | ||
14 | // --------------------------------------------------------------------------- | ||
15 | |||
16 | // Options builders | ||
17 | |||
18 | export type EncoderOptionsBuilder = (params: { | ||
19 | input: string | ||
20 | resolution: VideoResolution | ||
21 | fps?: number | ||
22 | }) => Promise<EncoderOptions> | EncoderOptions | ||
23 | |||
24 | // Options types | ||
25 | |||
26 | export interface EncoderOptions { | ||
27 | outputOptions: string[] | ||
28 | } | ||
29 | |||
30 | // All our encoders | ||
31 | |||
32 | export interface EncoderProfile <T> { | ||
33 | [ profile: string ]: T | ||
34 | |||
35 | default: T | ||
36 | } | ||
37 | |||
38 | export type AvailableEncoders = { | ||
39 | [ id in 'live' | 'vod' ]: { | ||
40 | [ encoder in 'libx264' | 'aac' | 'libfdkAAC' ]: EncoderProfile<EncoderOptionsBuilder> | ||
41 | } | ||
42 | } | ||
43 | |||
44 | // --------------------------------------------------------------------------- | ||
45 | // Image manipulation | ||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | function convertWebPToJPG (path: string, destination: string): Promise<void> { | ||
49 | const command = ffmpeg(path) | ||
50 | .output(destination) | ||
51 | |||
52 | return runCommand(command) | ||
53 | } | ||
54 | |||
55 | function processGIF ( | ||
56 | path: string, | ||
57 | destination: string, | ||
58 | newSize: { width: number, height: number }, | ||
59 | keepOriginal = false | ||
60 | ): Promise<void> { | ||
61 | return new Promise<void>(async (res, rej) => { | ||
62 | if (path === destination) { | ||
63 | throw new Error('FFmpeg needs an input path different that the output path.') | ||
64 | } | ||
65 | |||
66 | logger.debug('Processing gif %s to %s.', path, destination) | ||
67 | |||
68 | try { | ||
69 | const command = ffmpeg(path) | ||
70 | .fps(20) | ||
71 | .size(`${newSize.width}x${newSize.height}`) | ||
72 | .output(destination) | ||
73 | |||
74 | command.on('error', (err, stdout, stderr) => { | ||
75 | logger.error('Error in ffmpeg gif resizing process.', { stdout, stderr }) | ||
76 | return rej(err) | ||
77 | }) | ||
78 | .on('end', async () => { | ||
79 | if (keepOriginal !== true) await remove(path) | ||
80 | res() | ||
81 | }) | ||
82 | .run() | ||
83 | } catch (err) { | ||
84 | return rej(err) | ||
85 | } | ||
86 | }) | ||
87 | } | ||
88 | |||
12 | async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { | 89 | async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { |
13 | const pendingImageName = 'pending-' + imageName | 90 | const pendingImageName = 'pending-' + imageName |
14 | 91 | ||
@@ -49,9 +126,15 @@ type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | |||
49 | 126 | ||
50 | interface BaseTranscodeOptions { | 127 | interface BaseTranscodeOptions { |
51 | type: TranscodeOptionsType | 128 | type: TranscodeOptionsType |
129 | |||
52 | inputPath: string | 130 | inputPath: string |
53 | outputPath: string | 131 | outputPath: string |
132 | |||
133 | availableEncoders: AvailableEncoders | ||
134 | profile: string | ||
135 | |||
54 | resolution: VideoResolution | 136 | resolution: VideoResolution |
137 | |||
55 | isPortraitMode?: boolean | 138 | isPortraitMode?: boolean |
56 | } | 139 | } |
57 | 140 | ||
@@ -94,7 +177,7 @@ const builders: { | |||
94 | 'hls': buildHLSVODCommand, | 177 | 'hls': buildHLSVODCommand, |
95 | 'merge-audio': buildAudioMergeCommand, | 178 | 'merge-audio': buildAudioMergeCommand, |
96 | 'only-audio': buildOnlyAudioCommand, | 179 | 'only-audio': buildOnlyAudioCommand, |
97 | 'video': buildx264Command | 180 | 'video': buildx264VODCommand |
98 | } | 181 | } |
99 | 182 | ||
100 | async function transcode (options: TranscodeOptions) { | 183 | async function transcode (options: TranscodeOptions) { |
@@ -110,58 +193,11 @@ async function transcode (options: TranscodeOptions) { | |||
110 | await fixHLSPlaylistIfNeeded(options) | 193 | await fixHLSPlaylistIfNeeded(options) |
111 | } | 194 | } |
112 | 195 | ||
113 | function convertWebPToJPG (path: string, destination: string): Promise<void> { | 196 | // --------------------------------------------------------------------------- |
114 | return new Promise<void>(async (res, rej) => { | 197 | // Live muxing/transcoding functions |
115 | try { | 198 | // --------------------------------------------------------------------------- |
116 | const command = ffmpeg(path).output(destination) | ||
117 | |||
118 | command.on('error', (err, stdout, stderr) => { | ||
119 | logger.error('Error in ffmpeg webp convert process.', { stdout, stderr }) | ||
120 | return rej(err) | ||
121 | }) | ||
122 | .on('end', () => res()) | ||
123 | .run() | ||
124 | } catch (err) { | ||
125 | return rej(err) | ||
126 | } | ||
127 | }) | ||
128 | } | ||
129 | |||
130 | function processGIF ( | ||
131 | path: string, | ||
132 | destination: string, | ||
133 | newSize: { width: number, height: number }, | ||
134 | keepOriginal = false | ||
135 | ): Promise<void> { | ||
136 | return new Promise<void>(async (res, rej) => { | ||
137 | if (path === destination) { | ||
138 | throw new Error('FFmpeg needs an input path different that the output path.') | ||
139 | } | ||
140 | |||
141 | logger.debug('Processing gif %s to %s.', path, destination) | ||
142 | |||
143 | try { | ||
144 | const command = ffmpeg(path) | ||
145 | .fps(20) | ||
146 | .size(`${newSize.width}x${newSize.height}`) | ||
147 | .output(destination) | ||
148 | |||
149 | command.on('error', (err, stdout, stderr) => { | ||
150 | logger.error('Error in ffmpeg gif resizing process.', { stdout, stderr }) | ||
151 | return rej(err) | ||
152 | }) | ||
153 | .on('end', async () => { | ||
154 | if (keepOriginal !== true) await remove(path) | ||
155 | res() | ||
156 | }) | ||
157 | .run() | ||
158 | } catch (err) { | ||
159 | return rej(err) | ||
160 | } | ||
161 | }) | ||
162 | } | ||
163 | 199 | ||
164 | function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], fps, deleteSegments: boolean) { | 200 | function getLiveTranscodingCommand (rtmpUrl: string, outPath: string, resolutions: number[], fps: number, deleteSegments: boolean) { |
165 | const command = getFFmpeg(rtmpUrl) | 201 | const command = getFFmpeg(rtmpUrl) |
166 | command.inputOption('-fflags nobuffer') | 202 | command.inputOption('-fflags nobuffer') |
167 | 203 | ||
@@ -183,14 +219,9 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb | |||
183 | })) | 219 | })) |
184 | ]) | 220 | ]) |
185 | 221 | ||
186 | command.outputOption('-b_strategy 1') | 222 | addEncoderDefaultParams(command, 'libx264', fps) |
187 | command.outputOption('-bf 16') | 223 | |
188 | command.outputOption('-preset superfast') | 224 | command.outputOption('-preset superfast') |
189 | command.outputOption('-level 3.1') | ||
190 | command.outputOption('-map_metadata -1') | ||
191 | command.outputOption('-pix_fmt yuv420p') | ||
192 | command.outputOption('-max_muxing_queue_size 1024') | ||
193 | command.outputOption('-g ' + (fps * 2)) | ||
194 | 225 | ||
195 | for (let i = 0; i < resolutions.length; i++) { | 226 | for (let i = 0; i < resolutions.length; i++) { |
196 | const resolution = resolutions[i] | 227 | const resolution = resolutions[i] |
@@ -209,12 +240,10 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb | |||
209 | 240 | ||
210 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) | 241 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) |
211 | 242 | ||
212 | command.run() | ||
213 | |||
214 | return command | 243 | return command |
215 | } | 244 | } |
216 | 245 | ||
217 | function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) { | 246 | function getLiveMuxingCommand (rtmpUrl: string, outPath: string, deleteSegments: boolean) { |
218 | const command = getFFmpeg(rtmpUrl) | 247 | const command = getFFmpeg(rtmpUrl) |
219 | command.inputOption('-fflags nobuffer') | 248 | command.inputOption('-fflags nobuffer') |
220 | 249 | ||
@@ -225,8 +254,6 @@ function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolea | |||
225 | 254 | ||
226 | addDefaultLiveHLSParams(command, outPath, deleteSegments) | 255 | addDefaultLiveHLSParams(command, outPath, deleteSegments) |
227 | 256 | ||
228 | command.run() | ||
229 | |||
230 | return command | 257 | return command |
231 | } | 258 | } |
232 | 259 | ||
@@ -255,32 +282,13 @@ async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: s | |||
255 | return runCommand(command, cleaner) | 282 | return runCommand(command, cleaner) |
256 | } | 283 | } |
257 | 284 | ||
258 | async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) { | ||
259 | return new Promise<string>((res, rej) => { | ||
260 | command.on('error', (err, stdout, stderr) => { | ||
261 | if (onEnd) onEnd() | ||
262 | |||
263 | logger.error('Error in transcoding job.', { stdout, stderr }) | ||
264 | rej(err) | ||
265 | }) | ||
266 | |||
267 | command.on('end', () => { | ||
268 | if (onEnd) onEnd() | ||
269 | |||
270 | res() | ||
271 | }) | ||
272 | |||
273 | command.run() | ||
274 | }) | ||
275 | } | ||
276 | |||
277 | // --------------------------------------------------------------------------- | 285 | // --------------------------------------------------------------------------- |
278 | 286 | ||
279 | export { | 287 | export { |
280 | runLiveMuxing, | 288 | getLiveTranscodingCommand, |
289 | getLiveMuxingCommand, | ||
281 | convertWebPToJPG, | 290 | convertWebPToJPG, |
282 | processGIF, | 291 | processGIF, |
283 | runLiveTranscoding, | ||
284 | generateImageFromVideoFile, | 292 | generateImageFromVideoFile, |
285 | TranscodeOptions, | 293 | TranscodeOptions, |
286 | TranscodeOptionsType, | 294 | TranscodeOptionsType, |
@@ -290,12 +298,23 @@ export { | |||
290 | 298 | ||
291 | // --------------------------------------------------------------------------- | 299 | // --------------------------------------------------------------------------- |
292 | 300 | ||
293 | function addDefaultX264Params (command: ffmpeg.FfmpegCommand) { | 301 | // --------------------------------------------------------------------------- |
302 | // Default options | ||
303 | // --------------------------------------------------------------------------- | ||
304 | |||
305 | function addEncoderDefaultParams (command: ffmpeg.FfmpegCommand, encoder: 'libx264' | string, fps?: number) { | ||
306 | if (encoder !== 'libx264') return | ||
307 | |||
294 | command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution | 308 | command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution |
295 | .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it | 309 | .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it |
296 | .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 | 310 | .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 |
297 | .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) | 311 | .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) |
298 | .outputOption('-map_metadata -1') // strip all metadata | 312 | .outputOption('-map_metadata -1') // strip all metadata |
313 | .outputOption('-max_muxing_queue_size 1024') // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 | ||
314 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | ||
315 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | ||
316 | // https://superuser.com/a/908325 | ||
317 | .outputOption('-g ' + (fps * 2)) | ||
299 | } | 318 | } |
300 | 319 | ||
301 | function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { | 320 | function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { |
@@ -313,7 +332,11 @@ function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string | |||
313 | command.output(join(outPath, '%v.m3u8')) | 332 | command.output(join(outPath, '%v.m3u8')) |
314 | } | 333 | } |
315 | 334 | ||
316 | async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { | 335 | // --------------------------------------------------------------------------- |
336 | // Transcode VOD command builders | ||
337 | // --------------------------------------------------------------------------- | ||
338 | |||
339 | async function buildx264VODCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { | ||
317 | let fps = await getVideoFileFPS(options.inputPath) | 340 | let fps = await getVideoFileFPS(options.inputPath) |
318 | if ( | 341 | if ( |
319 | // On small/medium resolutions, limit FPS | 342 | // On small/medium resolutions, limit FPS |
@@ -325,7 +348,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco | |||
325 | fps = getClosestFramerateStandard(fps, 'STANDARD') | 348 | fps = getClosestFramerateStandard(fps, 'STANDARD') |
326 | } | 349 | } |
327 | 350 | ||
328 | command = await presetH264(command, options.inputPath, options.resolution, fps) | 351 | command = await presetVideo(command, options.inputPath, options, fps) |
329 | 352 | ||
330 | if (options.resolution !== undefined) { | 353 | if (options.resolution !== undefined) { |
331 | // '?x720' or '720x?' for example | 354 | // '?x720' or '720x?' for example |
@@ -347,7 +370,16 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco | |||
347 | async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) { | 370 | async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) { |
348 | command = command.loop(undefined) | 371 | command = command.loop(undefined) |
349 | 372 | ||
350 | command = await presetH264VeryFast(command, options.audioPath, options.resolution) | 373 | command = await presetVideo(command, options.audioPath, options) |
374 | |||
375 | /* | ||
376 | MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html | ||
377 | Our target situation is closer to a livestream than a stream, | ||
378 | since we want to reduce as much a possible the encoding burden, | ||
379 | although not to the point of a livestream where there is a hard | ||
380 | constraint on the frames per second to be encoded. | ||
381 | */ | ||
382 | command.outputOption('-preset:v veryfast') | ||
351 | 383 | ||
352 | command = command.input(options.audioPath) | 384 | command = command.input(options.audioPath) |
353 | .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error | 385 | .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error |
@@ -377,7 +409,7 @@ async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTr | |||
377 | 409 | ||
378 | if (options.copyCodecs) command = presetCopy(command) | 410 | if (options.copyCodecs) command = presetCopy(command) |
379 | else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) | 411 | else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) |
380 | else command = await buildx264Command(command, options) | 412 | else command = await buildx264VODCommand(command, options) |
381 | 413 | ||
382 | command = command.outputOption('-hls_time 4') | 414 | command = command.outputOption('-hls_time 4') |
383 | .outputOption('-hls_list_size 0') | 415 | .outputOption('-hls_list_size 0') |
@@ -390,10 +422,6 @@ async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTr | |||
390 | return command | 422 | return command |
391 | } | 423 | } |
392 | 424 | ||
393 | function getHLSVideoPath (options: HLSTranscodeOptions) { | ||
394 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
395 | } | ||
396 | |||
397 | async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { | 425 | async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { |
398 | if (options.type !== 'hls') return | 426 | if (options.type !== 'hls') return |
399 | 427 | ||
@@ -409,76 +437,71 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { | |||
409 | await writeFile(options.outputPath, newContent) | 437 | await writeFile(options.outputPath, newContent) |
410 | } | 438 | } |
411 | 439 | ||
412 | /** | 440 | function getHLSVideoPath (options: HLSTranscodeOptions) { |
413 | * A slightly customised version of the 'veryfast' x264 preset | 441 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` |
414 | * | ||
415 | * The veryfast preset is right in the sweet spot of performance | ||
416 | * and quality. Superfast and ultrafast will give you better | ||
417 | * performance, but then quality is noticeably worse. | ||
418 | */ | ||
419 | async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) { | ||
420 | let localCommand = await presetH264(command, input, resolution, fps) | ||
421 | |||
422 | localCommand = localCommand.outputOption('-preset:v veryfast') | ||
423 | |||
424 | /* | ||
425 | MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html | ||
426 | Our target situation is closer to a livestream than a stream, | ||
427 | since we want to reduce as much a possible the encoding burden, | ||
428 | although not to the point of a livestream where there is a hard | ||
429 | constraint on the frames per second to be encoded. | ||
430 | */ | ||
431 | |||
432 | return localCommand | ||
433 | } | 442 | } |
434 | 443 | ||
435 | /** | 444 | // --------------------------------------------------------------------------- |
436 | * Standard profile, with variable bitrate audio and faststart. | 445 | // Transcoding presets |
437 | * | 446 | // --------------------------------------------------------------------------- |
438 | * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel | 447 | |
439 | * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr | 448 | async function presetVideo ( |
440 | */ | 449 | command: ffmpeg.FfmpegCommand, |
441 | async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) { | 450 | input: string, |
451 | transcodeOptions: TranscodeOptions, | ||
452 | fps?: number | ||
453 | ) { | ||
442 | let localCommand = command | 454 | let localCommand = command |
443 | .format('mp4') | 455 | .format('mp4') |
444 | .videoCodec('libx264') | ||
445 | .outputOption('-movflags faststart') | 456 | .outputOption('-movflags faststart') |
446 | 457 | ||
447 | addDefaultX264Params(localCommand) | 458 | // Audio encoder |
448 | |||
449 | const parsedAudio = await getAudioStream(input) | 459 | const parsedAudio = await getAudioStream(input) |
450 | 460 | ||
461 | let streamsToProcess = [ 'AUDIO', 'VIDEO' ] | ||
462 | const streamsFound = { | ||
463 | AUDIO: '', | ||
464 | VIDEO: '' | ||
465 | } | ||
466 | |||
451 | if (!parsedAudio.audioStream) { | 467 | if (!parsedAudio.audioStream) { |
452 | localCommand = localCommand.noAudio() | 468 | localCommand = localCommand.noAudio() |
453 | } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available | 469 | streamsToProcess = [ 'VIDEO' ] |
454 | localCommand = localCommand | 470 | } |
455 | .audioCodec('libfdk_aac') | ||
456 | .audioQuality(5) | ||
457 | } else { | ||
458 | // we try to reduce the ceiling bitrate by making rough matches of bitrates | ||
459 | // of course this is far from perfect, but it might save some space in the end | ||
460 | localCommand = localCommand.audioCodec('aac') | ||
461 | 471 | ||
462 | const audioCodecName = parsedAudio.audioStream['codec_name'] | 472 | for (const stream of streamsToProcess) { |
473 | const encodersToTry: string[] = VIDEO_TRANSCODING_ENCODERS[stream] | ||
463 | 474 | ||
464 | const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate) | 475 | for (const encoder of encodersToTry) { |
476 | if (!(await checkFFmpegEncoders()).get(encoder)) continue | ||
465 | 477 | ||
466 | if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) | 478 | const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = transcodeOptions.availableEncoders.vod[encoder] |
467 | } | 479 | let builder = builderProfiles[transcodeOptions.profile] |
468 | 480 | ||
469 | if (fps) { | 481 | if (!builder) { |
470 | // Constrained Encoding (VBV) | 482 | logger.debug('Profile %s for encoder %s not available. Fallback to default.', transcodeOptions.profile, encoder) |
471 | // https://slhck.info/video/2017/03/01/rate-control.html | 483 | builder = builderProfiles.default |
472 | // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate | 484 | } |
473 | const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) | 485 | |
474 | localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ]) | 486 | const builderResult = await builder({ input, resolution: transcodeOptions.resolution, fps }) |
475 | 487 | ||
476 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | 488 | logger.debug('Apply ffmpeg params from %s.', encoder, builderResult) |
477 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | 489 | |
478 | // https://superuser.com/a/908325 | 490 | localCommand.outputOptions(builderResult.outputOptions) |
479 | localCommand = localCommand.outputOption(`-g ${fps * 2}`) | 491 | |
492 | addEncoderDefaultParams(localCommand, encoder) | ||
493 | |||
494 | streamsFound[stream] = encoder | ||
495 | break | ||
496 | } | ||
497 | |||
498 | if (!streamsFound[stream]) { | ||
499 | throw new Error('No available encoder found ' + encodersToTry.join(', ')) | ||
500 | } | ||
480 | } | 501 | } |
481 | 502 | ||
503 | localCommand.videoCodec(streamsFound.VIDEO) | ||
504 | |||
482 | return localCommand | 505 | return localCommand |
483 | } | 506 | } |
484 | 507 | ||
@@ -496,6 +519,10 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand { | |||
496 | .noVideo() | 519 | .noVideo() |
497 | } | 520 | } |
498 | 521 | ||
522 | // --------------------------------------------------------------------------- | ||
523 | // Utils | ||
524 | // --------------------------------------------------------------------------- | ||
525 | |||
499 | function getFFmpeg (input: string) { | 526 | function getFFmpeg (input: string) { |
500 | // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems | 527 | // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems |
501 | const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR }) | 528 | const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR }) |
@@ -507,3 +534,22 @@ function getFFmpeg (input: string) { | |||
507 | 534 | ||
508 | return command | 535 | return command |
509 | } | 536 | } |
537 | |||
538 | async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) { | ||
539 | return new Promise<void>((res, rej) => { | ||
540 | command.on('error', (err, stdout, stderr) => { | ||
541 | if (onEnd) onEnd() | ||
542 | |||
543 | logger.error('Error in transcoding job.', { stdout, stderr }) | ||
544 | rej(err) | ||
545 | }) | ||
546 | |||
547 | command.on('end', () => { | ||
548 | if (onEnd) onEnd() | ||
549 | |||
550 | res() | ||
551 | }) | ||
552 | |||
553 | command.run() | ||
554 | }) | ||
555 | } | ||
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 93b71a242..4ffd6fad9 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts | |||
@@ -82,6 +82,8 @@ function checkMissedConfig () { | |||
82 | async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) { | 82 | async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) { |
83 | if (CONFIG.TRANSCODING.ENABLED === false) return undefined | 83 | if (CONFIG.TRANSCODING.ENABLED === false) return undefined |
84 | 84 | ||
85 | checkFFmpegEncoders() | ||
86 | |||
85 | const Ffmpeg = require('fluent-ffmpeg') | 87 | const Ffmpeg = require('fluent-ffmpeg') |
86 | const getAvailableCodecsPromise = promisify0(Ffmpeg.getAvailableCodecs) | 88 | const getAvailableCodecsPromise = promisify0(Ffmpeg.getAvailableCodecs) |
87 | const codecs = await getAvailableCodecsPromise() | 89 | const codecs = await getAvailableCodecsPromise() |
@@ -100,25 +102,30 @@ async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) { | |||
100 | return checkFFmpegEncoders() | 102 | return checkFFmpegEncoders() |
101 | } | 103 | } |
102 | 104 | ||
103 | // Optional encoders, if present, can be used to improve transcoding | 105 | // Detect supported encoders by ffmpeg |
104 | // Here we ask ffmpeg if it detects their presence on the system, so that we can later use them | 106 | let supportedEncoders: Map<string, boolean> |
105 | let supportedOptionalEncoders: Map<string, boolean> | ||
106 | async function checkFFmpegEncoders (): Promise<Map<string, boolean>> { | 107 | async function checkFFmpegEncoders (): Promise<Map<string, boolean>> { |
107 | if (supportedOptionalEncoders !== undefined) { | 108 | if (supportedEncoders !== undefined) { |
108 | return supportedOptionalEncoders | 109 | return supportedEncoders |
109 | } | 110 | } |
110 | 111 | ||
111 | const Ffmpeg = require('fluent-ffmpeg') | 112 | const Ffmpeg = require('fluent-ffmpeg') |
112 | const getAvailableEncodersPromise = promisify0(Ffmpeg.getAvailableEncoders) | 113 | const getAvailableEncodersPromise = promisify0(Ffmpeg.getAvailableEncoders) |
113 | const encoders = await getAvailableEncodersPromise() | 114 | const availableEncoders = await getAvailableEncodersPromise() |
114 | const optionalEncoders = [ 'libfdk_aac' ] | 115 | |
115 | supportedOptionalEncoders = new Map<string, boolean>() | 116 | const searchEncoders = [ |
117 | 'aac', | ||
118 | 'libfdk_aac', | ||
119 | 'libx264' | ||
120 | ] | ||
121 | |||
122 | supportedEncoders = new Map<string, boolean>() | ||
116 | 123 | ||
117 | for (const encoder of optionalEncoders) { | 124 | for (const searchEncoder of searchEncoders) { |
118 | supportedOptionalEncoders.set(encoder, encoders[encoder] !== undefined) | 125 | supportedEncoders.set(searchEncoder, availableEncoders[searchEncoder] !== undefined) |
119 | } | 126 | } |
120 | 127 | ||
121 | return supportedOptionalEncoders | 128 | return supportedEncoders |
122 | } | 129 | } |
123 | 130 | ||
124 | function checkNodeVersion () { | 131 | function checkNodeVersion () { |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 5c6d06077..945185f62 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -336,6 +336,17 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { | |||
336 | KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) | 336 | KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) |
337 | } | 337 | } |
338 | 338 | ||
339 | const VIDEO_TRANSCODING_ENCODERS = { | ||
340 | VIDEO: [ 'libx264' ], | ||
341 | |||
342 | // Try the first one, if not available try the second one etc | ||
343 | AUDIO: [ | ||
344 | // we favor VBR, if a good AAC encoder is available | ||
345 | 'libfdk_aac', | ||
346 | 'aac' | ||
347 | ] | ||
348 | } | ||
349 | |||
339 | const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P | 350 | const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P |
340 | 351 | ||
341 | const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = { | 352 | const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = { |
@@ -804,6 +815,7 @@ export { | |||
804 | ACTOR_FOLLOW_SCORE, | 815 | ACTOR_FOLLOW_SCORE, |
805 | PREVIEWS_SIZE, | 816 | PREVIEWS_SIZE, |
806 | REMOTE_SCHEME, | 817 | REMOTE_SCHEME, |
818 | VIDEO_TRANSCODING_ENCODERS, | ||
807 | FOLLOW_STATES, | 819 | FOLLOW_STATES, |
808 | DEFAULT_USER_THEME_NAME, | 820 | DEFAULT_USER_THEME_NAME, |
809 | SERVER_ACTOR_NAME, | 821 | SERVER_ACTOR_NAME, |
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts index 4d2e9b1b3..b9e5d4561 100644 --- a/server/lib/live-manager.ts +++ b/server/lib/live-manager.ts | |||
@@ -4,7 +4,7 @@ import { FfmpegCommand } from 'fluent-ffmpeg' | |||
4 | import { ensureDir, stat } from 'fs-extra' | 4 | import { ensureDir, stat } from 'fs-extra' |
5 | import { basename } from 'path' | 5 | import { basename } from 'path' |
6 | import { isTestInstance } from '@server/helpers/core-utils' | 6 | import { isTestInstance } from '@server/helpers/core-utils' |
7 | import { runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils' | 7 | import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils' |
8 | import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils' | 8 | import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils' |
9 | import { logger } from '@server/helpers/logger' | 9 | import { logger } from '@server/helpers/logger' |
10 | import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' | 10 | import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' |
@@ -264,8 +264,8 @@ class LiveManager { | |||
264 | const deleteSegments = videoLive.saveReplay === false | 264 | const deleteSegments = videoLive.saveReplay === false |
265 | 265 | ||
266 | const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED | 266 | const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED |
267 | ? runLiveTranscoding(rtmpUrl, outPath, allResolutions, fps, deleteSegments) | 267 | ? getLiveTranscodingCommand(rtmpUrl, outPath, allResolutions, fps, deleteSegments) |
268 | : runLiveMuxing(rtmpUrl, outPath, deleteSegments) | 268 | : getLiveMuxingCommand(rtmpUrl, outPath, deleteSegments) |
269 | 269 | ||
270 | logger.info('Running live muxing/transcoding for %s.', videoUUID) | 270 | logger.info('Running live muxing/transcoding for %s.', videoUUID) |
271 | this.transSessions.set(sessionId, ffmpegExec) | 271 | this.transSessions.set(sessionId, ffmpegExec) |
@@ -382,6 +382,8 @@ class LiveManager { | |||
382 | }) | 382 | }) |
383 | 383 | ||
384 | ffmpegExec.on('end', () => onFFmpegEnded()) | 384 | ffmpegExec.on('end', () => onFFmpegEnded()) |
385 | |||
386 | ffmpegExec.run() | ||
385 | } | 387 | } |
386 | 388 | ||
387 | private async onEndTransmuxing (videoId: number, cleanupNow = false) { | 389 | private async onEndTransmuxing (videoId: number, cleanupNow = false) { |
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index ca969b235..0e6a0e984 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts | |||
@@ -2,13 +2,26 @@ import { copyFile, ensureDir, move, remove, stat } from 'fs-extra' | |||
2 | import { basename, extname as extnameUtil, join } from 'path' | 2 | import { basename, extname as extnameUtil, join } from 'path' |
3 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 3 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' |
4 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' | 4 | import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' |
5 | import { VideoResolution } from '../../shared/models/videos' | 5 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' |
6 | import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' | 6 | import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' |
7 | import { transcode, TranscodeOptions, TranscodeOptionsType } from '../helpers/ffmpeg-utils' | 7 | import { AvailableEncoders, EncoderOptionsBuilder, transcode, TranscodeOptions, TranscodeOptionsType } from '../helpers/ffmpeg-utils' |
8 | import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../helpers/ffprobe-utils' | 8 | import { |
9 | canDoQuickTranscode, | ||
10 | getAudioStream, | ||
11 | getDurationFromVideoFile, | ||
12 | getMaxAudioBitrate, | ||
13 | getMetadataFromFile, | ||
14 | getVideoFileBitrate, | ||
15 | getVideoFileFPS | ||
16 | } from '../helpers/ffprobe-utils' | ||
9 | import { logger } from '../helpers/logger' | 17 | import { logger } from '../helpers/logger' |
10 | import { CONFIG } from '../initializers/config' | 18 | import { CONFIG } from '../initializers/config' |
11 | import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants' | 19 | import { |
20 | HLS_STREAMING_PLAYLIST_DIRECTORY, | ||
21 | P2P_MEDIA_LOADER_PEER_VERSION, | ||
22 | VIDEO_TRANSCODING_FPS, | ||
23 | WEBSERVER | ||
24 | } from '../initializers/constants' | ||
12 | import { VideoFileModel } from '../models/video/video-file' | 25 | import { VideoFileModel } from '../models/video/video-file' |
13 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' | 26 | import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' |
14 | import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls' | 27 | import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls' |
@@ -31,8 +44,13 @@ async function optimizeOriginalVideofile (video: MVideoWithFile, inputVideoFileA | |||
31 | 44 | ||
32 | const transcodeOptions: TranscodeOptions = { | 45 | const transcodeOptions: TranscodeOptions = { |
33 | type: transcodeType, | 46 | type: transcodeType, |
47 | |||
34 | inputPath: videoInputPath, | 48 | inputPath: videoInputPath, |
35 | outputPath: videoTranscodedPath, | 49 | outputPath: videoTranscodedPath, |
50 | |||
51 | availableEncoders, | ||
52 | profile: 'default', | ||
53 | |||
36 | resolution: inputVideoFile.resolution | 54 | resolution: inputVideoFile.resolution |
37 | } | 55 | } |
38 | 56 | ||
@@ -78,14 +96,23 @@ async function transcodeNewResolution (video: MVideoWithFile, resolution: VideoR | |||
78 | const transcodeOptions = resolution === VideoResolution.H_NOVIDEO | 96 | const transcodeOptions = resolution === VideoResolution.H_NOVIDEO |
79 | ? { | 97 | ? { |
80 | type: 'only-audio' as 'only-audio', | 98 | type: 'only-audio' as 'only-audio', |
99 | |||
81 | inputPath: videoInputPath, | 100 | inputPath: videoInputPath, |
82 | outputPath: videoTranscodedPath, | 101 | outputPath: videoTranscodedPath, |
102 | |||
103 | availableEncoders, | ||
104 | profile: 'default', | ||
105 | |||
83 | resolution | 106 | resolution |
84 | } | 107 | } |
85 | : { | 108 | : { |
86 | type: 'video' as 'video', | 109 | type: 'video' as 'video', |
87 | inputPath: videoInputPath, | 110 | inputPath: videoInputPath, |
88 | outputPath: videoTranscodedPath, | 111 | outputPath: videoTranscodedPath, |
112 | |||
113 | availableEncoders, | ||
114 | profile: 'default', | ||
115 | |||
89 | resolution, | 116 | resolution, |
90 | isPortraitMode: isPortrait | 117 | isPortraitMode: isPortrait |
91 | } | 118 | } |
@@ -111,8 +138,13 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video | |||
111 | 138 | ||
112 | const transcodeOptions = { | 139 | const transcodeOptions = { |
113 | type: 'merge-audio' as 'merge-audio', | 140 | type: 'merge-audio' as 'merge-audio', |
141 | |||
114 | inputPath: tmpPreviewPath, | 142 | inputPath: tmpPreviewPath, |
115 | outputPath: videoTranscodedPath, | 143 | outputPath: videoTranscodedPath, |
144 | |||
145 | availableEncoders, | ||
146 | profile: 'default', | ||
147 | |||
116 | audioPath: audioInputPath, | 148 | audioPath: audioInputPath, |
117 | resolution | 149 | resolution |
118 | } | 150 | } |
@@ -156,8 +188,13 @@ async function generateHlsPlaylist (options: { | |||
156 | 188 | ||
157 | const transcodeOptions = { | 189 | const transcodeOptions = { |
158 | type: 'hls' as 'hls', | 190 | type: 'hls' as 'hls', |
191 | |||
159 | inputPath: videoInputPath, | 192 | inputPath: videoInputPath, |
160 | outputPath, | 193 | outputPath, |
194 | |||
195 | availableEncoders, | ||
196 | profile: 'default', | ||
197 | |||
161 | resolution, | 198 | resolution, |
162 | copyCodecs, | 199 | copyCodecs, |
163 | isPortraitMode, | 200 | isPortraitMode, |
@@ -216,6 +253,75 @@ async function generateHlsPlaylist (options: { | |||
216 | } | 253 | } |
217 | 254 | ||
218 | // --------------------------------------------------------------------------- | 255 | // --------------------------------------------------------------------------- |
256 | // Available encoders profiles | ||
257 | // --------------------------------------------------------------------------- | ||
258 | |||
259 | const defaultX264OptionsBuilder: EncoderOptionsBuilder = async ({ input, resolution, fps }) => { | ||
260 | if (!fps) return { outputOptions: [] } | ||
261 | |||
262 | let targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) | ||
263 | |||
264 | // Don't transcode to an higher bitrate than the original file | ||
265 | const fileBitrate = await getVideoFileBitrate(input) | ||
266 | targetBitrate = Math.min(targetBitrate, fileBitrate) | ||
267 | |||
268 | return { | ||
269 | outputOptions: [ | ||
270 | // Constrained Encoding (VBV) | ||
271 | // https://slhck.info/video/2017/03/01/rate-control.html | ||
272 | // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate | ||
273 | `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` | ||
274 | ] | ||
275 | } | ||
276 | } | ||
277 | |||
278 | const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input }) => { | ||
279 | const parsedAudio = await getAudioStream(input) | ||
280 | |||
281 | // we try to reduce the ceiling bitrate by making rough matches of bitrates | ||
282 | // of course this is far from perfect, but it might save some space in the end | ||
283 | |||
284 | const audioCodecName = parsedAudio.audioStream['codec_name'] | ||
285 | |||
286 | const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate) | ||
287 | |||
288 | if (bitrate !== undefined && bitrate !== -1) { | ||
289 | return { outputOptions: [ '-b:a', bitrate + 'k' ] } | ||
290 | } | ||
291 | |||
292 | return { outputOptions: [] } | ||
293 | } | ||
294 | |||
295 | const defaultLibFDKAACOptionsBuilder: EncoderOptionsBuilder = () => { | ||
296 | return { outputOptions: [ '-aq', '5' ] } | ||
297 | } | ||
298 | |||
299 | const availableEncoders: AvailableEncoders = { | ||
300 | vod: { | ||
301 | libx264: { | ||
302 | default: defaultX264OptionsBuilder | ||
303 | }, | ||
304 | aac: { | ||
305 | default: defaultAACOptionsBuilder | ||
306 | }, | ||
307 | libfdkAAC: { | ||
308 | default: defaultLibFDKAACOptionsBuilder | ||
309 | } | ||
310 | }, | ||
311 | live: { | ||
312 | libx264: { | ||
313 | default: defaultX264OptionsBuilder | ||
314 | }, | ||
315 | aac: { | ||
316 | default: defaultAACOptionsBuilder | ||
317 | }, | ||
318 | libfdkAAC: { | ||
319 | default: defaultLibFDKAACOptionsBuilder | ||
320 | } | ||
321 | } | ||
322 | } | ||
323 | |||
324 | // --------------------------------------------------------------------------- | ||
219 | 325 | ||
220 | export { | 326 | export { |
221 | generateHlsPlaylist, | 327 | generateHlsPlaylist, |