]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/helpers/ffmpeg-utils.ts
Increase live segment file name
[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 runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], fps, deleteSegments: boolean) {
359 const command = getFFmpeg(rtmpUrl)
360 command.inputOption('-fflags nobuffer')
361
362 const varStreamMap: string[] = []
363
364 command.complexFilter([
365 {
366 inputs: '[v:0]',
367 filter: 'split',
368 options: resolutions.length,
369 outputs: resolutions.map(r => `vtemp${r}`)
370 },
371
372 ...resolutions.map(r => ({
373 inputs: `vtemp${r}`,
374 filter: 'scale',
375 options: `w=-2:h=${r}`,
376 outputs: `vout${r}`
377 }))
378 ])
379
380 command.outputOption('-b_strategy 1')
381 command.outputOption('-bf 16')
382 command.outputOption('-preset superfast')
383 command.outputOption('-level 3.1')
384 command.outputOption('-map_metadata -1')
385 command.outputOption('-pix_fmt yuv420p')
386 command.outputOption('-max_muxing_queue_size 1024')
387 command.outputOption('-g ' + (fps * 2))
388
389 for (let i = 0; i < resolutions.length; i++) {
390 const resolution = resolutions[i]
391
392 command.outputOption(`-map [vout${resolution}]`)
393 command.outputOption(`-c:v:${i} libx264`)
394 command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)}`)
395
396 command.outputOption(`-map a:0`)
397 command.outputOption(`-c:a:${i} aac`)
398
399 varStreamMap.push(`v:${i},a:${i}`)
400 }
401
402 addDefaultLiveHLSParams(command, outPath, deleteSegments)
403
404 command.outputOption('-var_stream_map', varStreamMap.join(' '))
405
406 command.run()
407
408 return command
409 }
410
411 function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
412 const command = getFFmpeg(rtmpUrl)
413 command.inputOption('-fflags nobuffer')
414
415 command.outputOption('-c:v copy')
416 command.outputOption('-c:a copy')
417 command.outputOption('-map 0:a?')
418 command.outputOption('-map 0:v?')
419
420 addDefaultLiveHLSParams(command, outPath, deleteSegments)
421
422 command.run()
423
424 return command
425 }
426
427 async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: string[], outputPath: string) {
428 const concatFilePath = join(hlsDirectory, 'concat.txt')
429
430 function cleaner () {
431 remove(concatFilePath)
432 .catch(err => logger.error('Cannot remove concat file in %s.', hlsDirectory, { err }))
433 }
434
435 // First concat the ts files to a mp4 file
436 const content = segmentFiles.map(f => 'file ' + f)
437 .join('\n')
438
439 await writeFile(concatFilePath, content + '\n')
440
441 const command = getFFmpeg(concatFilePath)
442 command.inputOption('-safe 0')
443 command.inputOption('-f concat')
444
445 command.outputOption('-c:v copy')
446 command.audioFilter('aresample=async=1:first_pts=0')
447 command.output(outputPath)
448
449 return runCommand(command, cleaner)
450 }
451
452 async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) {
453 command.run()
454
455 return new Promise<string>((res, rej) => {
456 command.on('error', err => {
457 if (onEnd) onEnd()
458
459 rej(err)
460 })
461
462 command.on('end', () => {
463 if (onEnd) onEnd()
464
465 res()
466 })
467 })
468 }
469
470 // ---------------------------------------------------------------------------
471
472 export {
473 getVideoStreamCodec,
474 getAudioStreamCodec,
475 runLiveMuxing,
476 convertWebPToJPG,
477 getVideoStreamSize,
478 getVideoFileResolution,
479 getMetadataFromFile,
480 getDurationFromVideoFile,
481 runLiveTranscoding,
482 generateImageFromVideoFile,
483 TranscodeOptions,
484 TranscodeOptionsType,
485 transcode,
486 getVideoFileFPS,
487 computeResolutionsToTranscode,
488 audio,
489 hlsPlaylistToFragmentedMP4,
490 getVideoFileBitrate,
491 canDoQuickTranscode
492 }
493
494 // ---------------------------------------------------------------------------
495
496 function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
497 command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
498 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
499 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
500 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
501 .outputOption('-map_metadata -1') // strip all metadata
502 }
503
504 function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) {
505 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
506 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
507
508 if (deleteSegments === true) {
509 command.outputOption('-hls_flags delete_segments')
510 }
511
512 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
513 command.outputOption('-master_pl_name master.m3u8')
514 command.outputOption(`-f hls`)
515
516 command.output(join(outPath, '%v.m3u8'))
517 }
518
519 async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
520 let fps = await getVideoFileFPS(options.inputPath)
521 if (
522 // On small/medium resolutions, limit FPS
523 options.resolution !== undefined &&
524 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
525 fps > VIDEO_TRANSCODING_FPS.AVERAGE
526 ) {
527 // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value
528 fps = getClosestFramerateStandard(fps, 'STANDARD')
529 }
530
531 command = await presetH264(command, options.inputPath, options.resolution, fps)
532
533 if (options.resolution !== undefined) {
534 // '?x720' or '720x?' for example
535 const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
536 command = command.size(size)
537 }
538
539 if (fps) {
540 // Hard FPS limits
541 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD')
542 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
543
544 command = command.withFPS(fps)
545 }
546
547 return command
548 }
549
550 async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
551 command = command.loop(undefined)
552
553 command = await presetH264VeryFast(command, options.audioPath, options.resolution)
554
555 command = command.input(options.audioPath)
556 .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
557 .outputOption('-tune stillimage')
558 .outputOption('-shortest')
559
560 return command
561 }
562
563 function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, options: OnlyAudioTranscodeOptions) {
564 command = presetOnlyAudio(command)
565
566 return command
567 }
568
569 function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
570 command = presetCopy(command)
571
572 command = command.outputOption('-map_metadata -1') // strip all metadata
573 .outputOption('-movflags faststart')
574
575 return command
576 }
577
578 async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
579 const videoPath = getHLSVideoPath(options)
580
581 if (options.copyCodecs) command = presetCopy(command)
582 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
583 else command = await buildx264Command(command, options)
584
585 command = command.outputOption('-hls_time 4')
586 .outputOption('-hls_list_size 0')
587 .outputOption('-hls_playlist_type vod')
588 .outputOption('-hls_segment_filename ' + videoPath)
589 .outputOption('-hls_segment_type fmp4')
590 .outputOption('-f hls')
591 .outputOption('-hls_flags single_file')
592
593 return command
594 }
595
596 function getHLSVideoPath (options: HLSTranscodeOptions) {
597 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
598 }
599
600 async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
601 if (options.type !== 'hls') return
602
603 const fileContent = await readFile(options.outputPath)
604
605 const videoFileName = options.hlsPlaylist.videoFilename
606 const videoFilePath = getHLSVideoPath(options)
607
608 // Fix wrong mapping with some ffmpeg versions
609 const newContent = fileContent.toString()
610 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
611
612 await writeFile(options.outputPath, newContent)
613 }
614
615 /**
616 * A slightly customised version of the 'veryfast' x264 preset
617 *
618 * The veryfast preset is right in the sweet spot of performance
619 * and quality. Superfast and ultrafast will give you better
620 * performance, but then quality is noticeably worse.
621 */
622 async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
623 let localCommand = await presetH264(command, input, resolution, fps)
624
625 localCommand = localCommand.outputOption('-preset:v veryfast')
626
627 /*
628 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
629 Our target situation is closer to a livestream than a stream,
630 since we want to reduce as much a possible the encoding burden,
631 although not to the point of a livestream where there is a hard
632 constraint on the frames per second to be encoded.
633 */
634
635 return localCommand
636 }
637
638 /**
639 * Standard profile, with variable bitrate audio and faststart.
640 *
641 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
642 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
643 */
644 async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
645 let localCommand = command
646 .format('mp4')
647 .videoCodec('libx264')
648 .outputOption('-movflags faststart')
649
650 addDefaultX264Params(localCommand)
651
652 const parsedAudio = await audio.get(input)
653
654 if (!parsedAudio.audioStream) {
655 localCommand = localCommand.noAudio()
656 } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available
657 localCommand = localCommand
658 .audioCodec('libfdk_aac')
659 .audioQuality(5)
660 } else {
661 // we try to reduce the ceiling bitrate by making rough matches of bitrates
662 // of course this is far from perfect, but it might save some space in the end
663 localCommand = localCommand.audioCodec('aac')
664
665 const audioCodecName = parsedAudio.audioStream['codec_name']
666
667 if (audio.bitrate[audioCodecName]) {
668 const bitrate = audio.bitrate[audioCodecName](parsedAudio.audioStream['bit_rate'])
669 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
670 }
671 }
672
673 if (fps) {
674 // Constrained Encoding (VBV)
675 // https://slhck.info/video/2017/03/01/rate-control.html
676 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
677 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
678 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
679
680 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
681 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
682 // https://superuser.com/a/908325
683 localCommand = localCommand.outputOption(`-g ${fps * 2}`)
684 }
685
686 return localCommand
687 }
688
689 function presetCopy (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
690 return command
691 .format('mp4')
692 .videoCodec('copy')
693 .audioCodec('copy')
694 }
695
696 function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
697 return command
698 .format('mp4')
699 .audioCodec('copy')
700 .noVideo()
701 }
702
703 function getFFmpeg (input: string) {
704 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
705 const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
706
707 if (CONFIG.TRANSCODING.THREADS > 0) {
708 // If we don't set any threads ffmpeg will chose automatically
709 command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
710 }
711
712 return command
713 }