diff options
Diffstat (limited to 'server/helpers')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 346 |
1 files changed, 196 insertions, 150 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 | } | ||