]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/helpers/ffmpeg-utils.ts
Add pixel size to tooltip and gif support with FFmpeg for avatar upload (#3329)
[github/Chocobozzz/PeerTube.git] / server / helpers / ffmpeg-utils.ts
1 import * as ffmpeg from 'fluent-ffmpeg'
2 import { readFile, remove, writeFile } from 'fs-extra'
3 import { dirname, join } from 'path'
4 import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
5 import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
6 import { checkFFmpegEncoders } from '../initializers/checker-before-init'
7 import { CONFIG } from '../initializers/config'
8 import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
9 import { processImage } from './image-utils'
10 import { logger } from './logger'
11
12 /**
13 * A toolbox to play with audio
14 */
15 namespace audio {
16 export const get = (videoPath: string) => {
17 // without position, ffprobe considers the last input only
18 // we make it consider the first input only
19 // if you pass a file path to pos, then ffprobe acts on that file directly
20 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
21
22 function parseFfprobe (err: any, data: ffmpeg.FfprobeData) {
23 if (err) return rej(err)
24
25 if ('streams' in data) {
26 const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
27 if (audioStream) {
28 return res({
29 absolutePath: data.format.filename,
30 audioStream
31 })
32 }
33 }
34
35 return res({ absolutePath: data.format.filename })
36 }
37
38 return ffmpeg.ffprobe(videoPath, parseFfprobe)
39 })
40 }
41
42 export namespace bitrate {
43 const baseKbitrate = 384
44
45 const toBits = (kbits: number) => kbits * 8000
46
47 export const aac = (bitrate: number): number => {
48 switch (true) {
49 case bitrate > toBits(baseKbitrate):
50 return baseKbitrate
51
52 default:
53 return -1 // we interpret it as a signal to copy the audio stream as is
54 }
55 }
56
57 export const mp3 = (bitrate: number): number => {
58 /*
59 a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
60 That's why, when using aac, we can go to lower kbit/sec. The equivalences
61 made here are not made to be accurate, especially with good mp3 encoders.
62 */
63 switch (true) {
64 case bitrate <= toBits(192):
65 return 128
66
67 case bitrate <= toBits(384):
68 return 256
69
70 default:
71 return baseKbitrate
72 }
73 }
74 }
75 }
76
77 function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
78 const configResolutions = type === 'vod'
79 ? CONFIG.TRANSCODING.RESOLUTIONS
80 : CONFIG.LIVE.TRANSCODING.RESOLUTIONS
81
82 const resolutionsEnabled: number[] = []
83
84 // Put in the order we want to proceed jobs
85 const resolutions = [
86 VideoResolution.H_NOVIDEO,
87 VideoResolution.H_480P,
88 VideoResolution.H_360P,
89 VideoResolution.H_720P,
90 VideoResolution.H_240P,
91 VideoResolution.H_1080P,
92 VideoResolution.H_4K
93 ]
94
95 for (const resolution of resolutions) {
96 if (configResolutions[resolution + 'p'] === true && videoFileResolution > resolution) {
97 resolutionsEnabled.push(resolution)
98 }
99 }
100
101 return resolutionsEnabled
102 }
103
104 async function getVideoStreamSize (path: string) {
105 const videoStream = await getVideoStreamFromFile(path)
106
107 return videoStream === null
108 ? { width: 0, height: 0 }
109 : { width: videoStream.width, height: videoStream.height }
110 }
111
112 async function getVideoStreamCodec (path: string) {
113 const videoStream = await getVideoStreamFromFile(path)
114
115 if (!videoStream) return ''
116
117 const videoCodec = videoStream.codec_tag_string
118
119 const baseProfileMatrix = {
120 High: '6400',
121 Main: '4D40',
122 Baseline: '42E0'
123 }
124
125 let baseProfile = baseProfileMatrix[videoStream.profile]
126 if (!baseProfile) {
127 logger.warn('Cannot get video profile codec of %s.', path, { videoStream })
128 baseProfile = baseProfileMatrix['High'] // Fallback
129 }
130
131 let level = videoStream.level.toString(16)
132 if (level.length === 1) level = `0${level}`
133
134 return `${videoCodec}.${baseProfile}${level}`
135 }
136
137 async function getAudioStreamCodec (path: string) {
138 const { audioStream } = await audio.get(path)
139
140 if (!audioStream) return ''
141
142 const audioCodec = audioStream.codec_name
143 if (audioCodec === 'aac') return 'mp4a.40.2'
144
145 logger.warn('Cannot get audio codec of %s.', path, { audioStream })
146
147 return 'mp4a.40.2' // Fallback
148 }
149
150 async function getVideoFileResolution (path: string) {
151 const size = await getVideoStreamSize(path)
152
153 return {
154 videoFileResolution: Math.min(size.height, size.width),
155 isPortraitMode: size.height > size.width
156 }
157 }
158
159 async function getVideoFileFPS (path: string) {
160 const videoStream = await getVideoStreamFromFile(path)
161 if (videoStream === null) return 0
162
163 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
164 const valuesText: string = videoStream[key]
165 if (!valuesText) continue
166
167 const [ frames, seconds ] = valuesText.split('/')
168 if (!frames || !seconds) continue
169
170 const result = parseInt(frames, 10) / parseInt(seconds, 10)
171 if (result > 0) return Math.round(result)
172 }
173
174 return 0
175 }
176
177 async function getMetadataFromFile <T> (path: string, cb = metadata => metadata) {
178 return new Promise<T>((res, rej) => {
179 ffmpeg.ffprobe(path, (err, metadata) => {
180 if (err) return rej(err)
181
182 return res(cb(new VideoFileMetadata(metadata)))
183 })
184 })
185 }
186
187 async function getVideoFileBitrate (path: string) {
188 return getMetadataFromFile<number>(path, metadata => metadata.format.bit_rate)
189 }
190
191 function getDurationFromVideoFile (path: string) {
192 return getMetadataFromFile<number>(path, metadata => Math.floor(metadata.format.duration))
193 }
194
195 function getVideoStreamFromFile (path: string) {
196 return getMetadataFromFile<any>(path, metadata => metadata.streams.find(s => s.codec_type === 'video') || null)
197 }
198
199 async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
200 const pendingImageName = 'pending-' + imageName
201
202 const options = {
203 filename: pendingImageName,
204 count: 1,
205 folder
206 }
207
208 const pendingImagePath = join(folder, pendingImageName)
209
210 try {
211 await new Promise<string>((res, rej) => {
212 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
213 .on('error', rej)
214 .on('end', () => res(imageName))
215 .thumbnail(options)
216 })
217
218 const destination = join(folder, imageName)
219 await processImage(pendingImagePath, destination, size)
220 } catch (err) {
221 logger.error('Cannot generate image from video %s.', fromPath, { err })
222
223 try {
224 await remove(pendingImagePath)
225 } catch (err) {
226 logger.debug('Cannot remove pending image path after generation error.', { err })
227 }
228 }
229 }
230
231 type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
232
233 interface BaseTranscodeOptions {
234 type: TranscodeOptionsType
235 inputPath: string
236 outputPath: string
237 resolution: VideoResolution
238 isPortraitMode?: boolean
239 }
240
241 interface HLSTranscodeOptions extends BaseTranscodeOptions {
242 type: 'hls'
243 copyCodecs: boolean
244 hlsPlaylist: {
245 videoFilename: string
246 }
247 }
248
249 interface QuickTranscodeOptions extends BaseTranscodeOptions {
250 type: 'quick-transcode'
251 }
252
253 interface VideoTranscodeOptions extends BaseTranscodeOptions {
254 type: 'video'
255 }
256
257 interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
258 type: 'merge-audio'
259 audioPath: string
260 }
261
262 interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
263 type: 'only-audio'
264 }
265
266 type TranscodeOptions =
267 HLSTranscodeOptions
268 | VideoTranscodeOptions
269 | MergeAudioTranscodeOptions
270 | OnlyAudioTranscodeOptions
271 | QuickTranscodeOptions
272
273 function transcode (options: TranscodeOptions) {
274 logger.debug('Will run transcode.', { options })
275
276 return new Promise<void>(async (res, rej) => {
277 try {
278 let command = getFFmpeg(options.inputPath)
279 .output(options.outputPath)
280
281 if (options.type === 'quick-transcode') {
282 command = buildQuickTranscodeCommand(command)
283 } else if (options.type === 'hls') {
284 command = await buildHLSVODCommand(command, options)
285 } else if (options.type === 'merge-audio') {
286 command = await buildAudioMergeCommand(command, options)
287 } else if (options.type === 'only-audio') {
288 command = buildOnlyAudioCommand(command, options)
289 } else {
290 command = await buildx264Command(command, options)
291 }
292
293 command
294 .on('error', (err, stdout, stderr) => {
295 logger.error('Error in transcoding job.', { stdout, stderr })
296 return rej(err)
297 })
298 .on('end', () => {
299 return fixHLSPlaylistIfNeeded(options)
300 .then(() => res())
301 .catch(err => rej(err))
302 })
303 .run()
304 } catch (err) {
305 return rej(err)
306 }
307 })
308 }
309
310 async function canDoQuickTranscode (path: string): Promise<boolean> {
311 // NOTE: This could be optimized by running ffprobe only once (but it runs fast anyway)
312 const videoStream = await getVideoStreamFromFile(path)
313 const parsedAudio = await audio.get(path)
314 const fps = await getVideoFileFPS(path)
315 const bitRate = await getVideoFileBitrate(path)
316 const resolution = await getVideoFileResolution(path)
317
318 // check video params
319 if (videoStream == null) return false
320 if (videoStream['codec_name'] !== 'h264') return false
321 if (videoStream['pix_fmt'] !== 'yuv420p') return false
322 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
323 if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false
324
325 // check audio params (if audio stream exists)
326 if (parsedAudio.audioStream) {
327 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
328
329 const maxAudioBitrate = audio.bitrate['aac'](parsedAudio.audioStream['bit_rate'])
330 if (maxAudioBitrate !== -1 && parsedAudio.audioStream['bit_rate'] > maxAudioBitrate) return false
331 }
332
333 return true
334 }
335
336 function getClosestFramerateStandard (fps: number, type: 'HD_STANDARD' | 'STANDARD'): number {
337 return VIDEO_TRANSCODING_FPS[type].slice(0)
338 .sort((a, b) => fps % a - fps % b)[0]
339 }
340
341 function convertWebPToJPG (path: string, destination: string): Promise<void> {
342 return new Promise<void>(async (res, rej) => {
343 try {
344 const command = ffmpeg(path).output(destination)
345
346 command.on('error', (err, stdout, stderr) => {
347 logger.error('Error in ffmpeg webp convert process.', { stdout, stderr })
348 return rej(err)
349 })
350 .on('end', () => res())
351 .run()
352 } catch (err) {
353 return rej(err)
354 }
355 })
356 }
357
358 function processGIF (
359 path: string,
360 destination: string,
361 newSize: { width: number, height: number },
362 keepOriginal = false
363 ): Promise<void> {
364 return new Promise<void>(async (res, rej) => {
365 if (path === destination) {
366 throw new Error('FFmpeg needs an input path different that the output path.')
367 }
368
369 logger.debug('Processing gif %s to %s.', path, destination)
370
371 try {
372 const command = ffmpeg(path)
373 .fps(20)
374 .size(`${newSize.width}x${newSize.height}`)
375 .output(destination)
376
377 command.on('error', (err, stdout, stderr) => {
378 logger.error('Error in ffmpeg gif resizing process.', { stdout, stderr })
379 return rej(err)
380 })
381 .on('end', async () => {
382 if (keepOriginal !== true) await remove(path)
383 res()
384 })
385 .run()
386 } catch (err) {
387 return rej(err)
388 }
389 })
390 }
391
392 function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], fps, deleteSegments: boolean) {
393 const command = getFFmpeg(rtmpUrl)
394 command.inputOption('-fflags nobuffer')
395
396 const varStreamMap: string[] = []
397
398 command.complexFilter([
399 {
400 inputs: '[v:0]',
401 filter: 'split',
402 options: resolutions.length,
403 outputs: resolutions.map(r => `vtemp${r}`)
404 },
405
406 ...resolutions.map(r => ({
407 inputs: `vtemp${r}`,
408 filter: 'scale',
409 options: `w=-2:h=${r}`,
410 outputs: `vout${r}`
411 }))
412 ])
413
414 command.outputOption('-b_strategy 1')
415 command.outputOption('-bf 16')
416 command.outputOption('-preset superfast')
417 command.outputOption('-level 3.1')
418 command.outputOption('-map_metadata -1')
419 command.outputOption('-pix_fmt yuv420p')
420 command.outputOption('-max_muxing_queue_size 1024')
421 command.outputOption('-g ' + (fps * 2))
422
423 for (let i = 0; i < resolutions.length; i++) {
424 const resolution = resolutions[i]
425
426 command.outputOption(`-map [vout${resolution}]`)
427 command.outputOption(`-c:v:${i} libx264`)
428 command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)}`)
429
430 command.outputOption(`-map a:0`)
431 command.outputOption(`-c:a:${i} aac`)
432
433 varStreamMap.push(`v:${i},a:${i}`)
434 }
435
436 addDefaultLiveHLSParams(command, outPath, deleteSegments)
437
438 command.outputOption('-var_stream_map', varStreamMap.join(' '))
439
440 command.run()
441
442 return command
443 }
444
445 function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
446 const command = getFFmpeg(rtmpUrl)
447 command.inputOption('-fflags nobuffer')
448
449 command.outputOption('-c:v copy')
450 command.outputOption('-c:a copy')
451 command.outputOption('-map 0:a?')
452 command.outputOption('-map 0:v?')
453
454 addDefaultLiveHLSParams(command, outPath, deleteSegments)
455
456 command.run()
457
458 return command
459 }
460
461 async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: string[], outputPath: string) {
462 const concatFilePath = join(hlsDirectory, 'concat.txt')
463
464 function cleaner () {
465 remove(concatFilePath)
466 .catch(err => logger.error('Cannot remove concat file in %s.', hlsDirectory, { err }))
467 }
468
469 // First concat the ts files to a mp4 file
470 const content = segmentFiles.map(f => 'file ' + f)
471 .join('\n')
472
473 await writeFile(concatFilePath, content + '\n')
474
475 const command = getFFmpeg(concatFilePath)
476 command.inputOption('-safe 0')
477 command.inputOption('-f concat')
478
479 command.outputOption('-c:v copy')
480 command.audioFilter('aresample=async=1:first_pts=0')
481 command.output(outputPath)
482
483 return runCommand(command, cleaner)
484 }
485
486 async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) {
487 command.run()
488
489 return new Promise<string>((res, rej) => {
490 command.on('error', err => {
491 if (onEnd) onEnd()
492
493 rej(err)
494 })
495
496 command.on('end', () => {
497 if (onEnd) onEnd()
498
499 res()
500 })
501 })
502 }
503
504 // ---------------------------------------------------------------------------
505
506 export {
507 getVideoStreamCodec,
508 getAudioStreamCodec,
509 runLiveMuxing,
510 convertWebPToJPG,
511 processGIF,
512 getVideoStreamSize,
513 getVideoFileResolution,
514 getMetadataFromFile,
515 getDurationFromVideoFile,
516 runLiveTranscoding,
517 generateImageFromVideoFile,
518 TranscodeOptions,
519 TranscodeOptionsType,
520 transcode,
521 getVideoFileFPS,
522 computeResolutionsToTranscode,
523 audio,
524 hlsPlaylistToFragmentedMP4,
525 getVideoFileBitrate,
526 canDoQuickTranscode
527 }
528
529 // ---------------------------------------------------------------------------
530
531 function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
532 command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
533 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
534 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
535 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
536 .outputOption('-map_metadata -1') // strip all metadata
537 }
538
539 function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) {
540 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
541 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
542
543 if (deleteSegments === true) {
544 command.outputOption('-hls_flags delete_segments')
545 }
546
547 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
548 command.outputOption('-master_pl_name master.m3u8')
549 command.outputOption(`-f hls`)
550
551 command.output(join(outPath, '%v.m3u8'))
552 }
553
554 async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
555 let fps = await getVideoFileFPS(options.inputPath)
556 if (
557 // On small/medium resolutions, limit FPS
558 options.resolution !== undefined &&
559 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
560 fps > VIDEO_TRANSCODING_FPS.AVERAGE
561 ) {
562 // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value
563 fps = getClosestFramerateStandard(fps, 'STANDARD')
564 }
565
566 command = await presetH264(command, options.inputPath, options.resolution, fps)
567
568 if (options.resolution !== undefined) {
569 // '?x720' or '720x?' for example
570 const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
571 command = command.size(size)
572 }
573
574 if (fps) {
575 // Hard FPS limits
576 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD')
577 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
578
579 command = command.withFPS(fps)
580 }
581
582 return command
583 }
584
585 async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
586 command = command.loop(undefined)
587
588 command = await presetH264VeryFast(command, options.audioPath, options.resolution)
589
590 command = command.input(options.audioPath)
591 .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
592 .outputOption('-tune stillimage')
593 .outputOption('-shortest')
594
595 return command
596 }
597
598 function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, options: OnlyAudioTranscodeOptions) {
599 command = presetOnlyAudio(command)
600
601 return command
602 }
603
604 function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
605 command = presetCopy(command)
606
607 command = command.outputOption('-map_metadata -1') // strip all metadata
608 .outputOption('-movflags faststart')
609
610 return command
611 }
612
613 async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
614 const videoPath = getHLSVideoPath(options)
615
616 if (options.copyCodecs) command = presetCopy(command)
617 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
618 else command = await buildx264Command(command, options)
619
620 command = command.outputOption('-hls_time 4')
621 .outputOption('-hls_list_size 0')
622 .outputOption('-hls_playlist_type vod')
623 .outputOption('-hls_segment_filename ' + videoPath)
624 .outputOption('-hls_segment_type fmp4')
625 .outputOption('-f hls')
626 .outputOption('-hls_flags single_file')
627
628 return command
629 }
630
631 function getHLSVideoPath (options: HLSTranscodeOptions) {
632 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
633 }
634
635 async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
636 if (options.type !== 'hls') return
637
638 const fileContent = await readFile(options.outputPath)
639
640 const videoFileName = options.hlsPlaylist.videoFilename
641 const videoFilePath = getHLSVideoPath(options)
642
643 // Fix wrong mapping with some ffmpeg versions
644 const newContent = fileContent.toString()
645 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
646
647 await writeFile(options.outputPath, newContent)
648 }
649
650 /**
651 * A slightly customised version of the 'veryfast' x264 preset
652 *
653 * The veryfast preset is right in the sweet spot of performance
654 * and quality. Superfast and ultrafast will give you better
655 * performance, but then quality is noticeably worse.
656 */
657 async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
658 let localCommand = await presetH264(command, input, resolution, fps)
659
660 localCommand = localCommand.outputOption('-preset:v veryfast')
661
662 /*
663 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
664 Our target situation is closer to a livestream than a stream,
665 since we want to reduce as much a possible the encoding burden,
666 although not to the point of a livestream where there is a hard
667 constraint on the frames per second to be encoded.
668 */
669
670 return localCommand
671 }
672
673 /**
674 * Standard profile, with variable bitrate audio and faststart.
675 *
676 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
677 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
678 */
679 async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
680 let localCommand = command
681 .format('mp4')
682 .videoCodec('libx264')
683 .outputOption('-movflags faststart')
684
685 addDefaultX264Params(localCommand)
686
687 const parsedAudio = await audio.get(input)
688
689 if (!parsedAudio.audioStream) {
690 localCommand = localCommand.noAudio()
691 } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available
692 localCommand = localCommand
693 .audioCodec('libfdk_aac')
694 .audioQuality(5)
695 } else {
696 // we try to reduce the ceiling bitrate by making rough matches of bitrates
697 // of course this is far from perfect, but it might save some space in the end
698 localCommand = localCommand.audioCodec('aac')
699
700 const audioCodecName = parsedAudio.audioStream['codec_name']
701
702 if (audio.bitrate[audioCodecName]) {
703 const bitrate = audio.bitrate[audioCodecName](parsedAudio.audioStream['bit_rate'])
704 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
705 }
706 }
707
708 if (fps) {
709 // Constrained Encoding (VBV)
710 // https://slhck.info/video/2017/03/01/rate-control.html
711 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
712 let targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
713
714 // Don't transcode to an higher bitrate than the original file
715 const fileBitrate = await getVideoFileBitrate(input)
716 targetBitrate = Math.min(targetBitrate, fileBitrate)
717
718 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
719
720 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
721 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
722 // https://superuser.com/a/908325
723 localCommand = localCommand.outputOption(`-g ${fps * 2}`)
724 }
725
726 return localCommand
727 }
728
729 function presetCopy (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
730 return command
731 .format('mp4')
732 .videoCodec('copy')
733 .audioCodec('copy')
734 }
735
736 function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
737 return command
738 .format('mp4')
739 .audioCodec('copy')
740 .noVideo()
741 }
742
743 function getFFmpeg (input: string) {
744 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
745 const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
746
747 if (CONFIG.TRANSCODING.THREADS > 0) {
748 // If we don't set any threads ffmpeg will chose automatically
749 command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
750 }
751
752 return command
753 }