]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/helpers/ffmpeg-utils.ts
Remove resumable cache after upload success
[github/Chocobozzz/PeerTube.git] / server / helpers / ffmpeg-utils.ts
1 import { Job } from 'bull'
2 import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
3 import { readFile, remove, writeFile } from 'fs-extra'
4 import { dirname, join } from 'path'
5 import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
6 import { pick } from '@shared/core-utils'
7 import {
8 AvailableEncoders,
9 EncoderOptions,
10 EncoderOptionsBuilder,
11 EncoderOptionsBuilderParams,
12 EncoderProfile,
13 VideoResolution
14 } from '../../shared/models/videos'
15 import { CONFIG } from '../initializers/config'
16 import { execPromise, promisify0 } from './core-utils'
17 import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
18 import { processImage } from './image-utils'
19 import { logger } from './logger'
20
21 /**
22 *
23 * Functions that run transcoding/muxing ffmpeg processes
24 * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
25 *
26 */
27
28 // ---------------------------------------------------------------------------
29 // Encoder options
30 // ---------------------------------------------------------------------------
31
32 type StreamType = 'audio' | 'video'
33
34 // ---------------------------------------------------------------------------
35 // Encoders support
36 // ---------------------------------------------------------------------------
37
38 // Detect supported encoders by ffmpeg
39 let supportedEncoders: Map<string, boolean>
40 async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
41 if (supportedEncoders !== undefined) {
42 return supportedEncoders
43 }
44
45 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
46 const availableFFmpegEncoders = await getAvailableEncodersPromise()
47
48 const searchEncoders = new Set<string>()
49 for (const type of [ 'live', 'vod' ]) {
50 for (const streamType of [ 'audio', 'video' ]) {
51 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
52 searchEncoders.add(encoder)
53 }
54 }
55 }
56
57 supportedEncoders = new Map<string, boolean>()
58
59 for (const searchEncoder of searchEncoders) {
60 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
61 }
62
63 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders })
64
65 return supportedEncoders
66 }
67
68 function resetSupportedEncoders () {
69 supportedEncoders = undefined
70 }
71
72 // ---------------------------------------------------------------------------
73 // Image manipulation
74 // ---------------------------------------------------------------------------
75
76 function convertWebPToJPG (path: string, destination: string): Promise<void> {
77 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
78 .output(destination)
79
80 return runCommand({ command, silent: true })
81 }
82
83 function processGIF (
84 path: string,
85 destination: string,
86 newSize: { width: number, height: number }
87 ): Promise<void> {
88 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
89 .fps(20)
90 .size(`${newSize.width}x${newSize.height}`)
91 .output(destination)
92
93 return runCommand({ command })
94 }
95
96 async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
97 const pendingImageName = 'pending-' + imageName
98
99 const options = {
100 filename: pendingImageName,
101 count: 1,
102 folder
103 }
104
105 const pendingImagePath = join(folder, pendingImageName)
106
107 try {
108 await new Promise<string>((res, rej) => {
109 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
110 .on('error', rej)
111 .on('end', () => res(imageName))
112 .thumbnail(options)
113 })
114
115 const destination = join(folder, imageName)
116 await processImage(pendingImagePath, destination, size)
117 } catch (err) {
118 logger.error('Cannot generate image from video %s.', fromPath, { err })
119
120 try {
121 await remove(pendingImagePath)
122 } catch (err) {
123 logger.debug('Cannot remove pending image path after generation error.', { err })
124 }
125 }
126 }
127
128 // ---------------------------------------------------------------------------
129 // Transcode meta function
130 // ---------------------------------------------------------------------------
131
132 type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
133
134 interface BaseTranscodeOptions {
135 type: TranscodeOptionsType
136
137 inputPath: string
138 outputPath: string
139
140 availableEncoders: AvailableEncoders
141 profile: string
142
143 resolution: number
144
145 isPortraitMode?: boolean
146
147 job?: Job
148 }
149
150 interface HLSTranscodeOptions extends BaseTranscodeOptions {
151 type: 'hls'
152 copyCodecs: boolean
153 hlsPlaylist: {
154 videoFilename: string
155 }
156 }
157
158 interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
159 type: 'hls-from-ts'
160
161 isAAC: boolean
162
163 hlsPlaylist: {
164 videoFilename: string
165 }
166 }
167
168 interface QuickTranscodeOptions extends BaseTranscodeOptions {
169 type: 'quick-transcode'
170 }
171
172 interface VideoTranscodeOptions extends BaseTranscodeOptions {
173 type: 'video'
174 }
175
176 interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
177 type: 'merge-audio'
178 audioPath: string
179 }
180
181 interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
182 type: 'only-audio'
183 }
184
185 type TranscodeOptions =
186 HLSTranscodeOptions
187 | HLSFromTSTranscodeOptions
188 | VideoTranscodeOptions
189 | MergeAudioTranscodeOptions
190 | OnlyAudioTranscodeOptions
191 | QuickTranscodeOptions
192
193 const builders: {
194 [ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand
195 } = {
196 'quick-transcode': buildQuickTranscodeCommand,
197 'hls': buildHLSVODCommand,
198 'hls-from-ts': buildHLSVODFromTSCommand,
199 'merge-audio': buildAudioMergeCommand,
200 'only-audio': buildOnlyAudioCommand,
201 'video': buildx264VODCommand
202 }
203
204 async function transcode (options: TranscodeOptions) {
205 logger.debug('Will run transcode.', { options })
206
207 let command = getFFmpeg(options.inputPath, 'vod')
208 .output(options.outputPath)
209
210 command = await builders[options.type](command, options)
211
212 await runCommand({ command, job: options.job })
213
214 await fixHLSPlaylistIfNeeded(options)
215 }
216
217 // ---------------------------------------------------------------------------
218 // Live muxing/transcoding functions
219 // ---------------------------------------------------------------------------
220
221 async function getLiveTranscodingCommand (options: {
222 inputUrl: string
223
224 outPath: string
225 masterPlaylistName: string
226
227 resolutions: number[]
228
229 // Input information
230 fps: number
231 bitrate: number
232 ratio: number
233
234 availableEncoders: AvailableEncoders
235 profile: string
236 }) {
237 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
238
239 const command = getFFmpeg(inputUrl, 'live')
240
241 const varStreamMap: string[] = []
242
243 const complexFilter: FilterSpecification[] = [
244 {
245 inputs: '[v:0]',
246 filter: 'split',
247 options: resolutions.length,
248 outputs: resolutions.map(r => `vtemp${r}`)
249 }
250 ]
251
252 command.outputOption('-sc_threshold 0')
253
254 addDefaultEncoderGlobalParams({ command })
255
256 for (let i = 0; i < resolutions.length; i++) {
257 const resolution = resolutions[i]
258 const resolutionFPS = computeFPS(fps, resolution)
259
260 const baseEncoderBuilderParams = {
261 input: inputUrl,
262
263 availableEncoders,
264 profile,
265
266 inputBitrate: bitrate,
267 inputRatio: ratio,
268
269 resolution,
270 fps: resolutionFPS,
271
272 streamNum: i,
273 videoType: 'live' as 'live'
274 }
275
276 {
277 const streamType: StreamType = 'video'
278 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
279 if (!builderResult) {
280 throw new Error('No available live video encoder found')
281 }
282
283 command.outputOption(`-map [vout${resolution}]`)
284
285 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
286
287 logger.debug('Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile, builderResult)
288
289 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
290 applyEncoderOptions(command, builderResult.result)
291
292 complexFilter.push({
293 inputs: `vtemp${resolution}`,
294 filter: getScaleFilter(builderResult.result),
295 options: `w=-2:h=${resolution}`,
296 outputs: `vout${resolution}`
297 })
298 }
299
300 {
301 const streamType: StreamType = 'audio'
302 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
303 if (!builderResult) {
304 throw new Error('No available live audio encoder found')
305 }
306
307 command.outputOption('-map a:0')
308
309 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
310
311 logger.debug('Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile, builderResult)
312
313 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
314 applyEncoderOptions(command, builderResult.result)
315 }
316
317 varStreamMap.push(`v:${i},a:${i}`)
318 }
319
320 command.complexFilter(complexFilter)
321
322 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
323
324 command.outputOption('-var_stream_map', varStreamMap.join(' '))
325
326 return command
327 }
328
329 function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
330 const command = getFFmpeg(inputUrl, 'live')
331
332 command.outputOption('-c:v copy')
333 command.outputOption('-c:a copy')
334 command.outputOption('-map 0:a?')
335 command.outputOption('-map 0:v?')
336
337 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
338
339 return command
340 }
341
342 function buildStreamSuffix (base: string, streamNum?: number) {
343 if (streamNum !== undefined) {
344 return `${base}:${streamNum}`
345 }
346
347 return base
348 }
349
350 // ---------------------------------------------------------------------------
351 // Default options
352 // ---------------------------------------------------------------------------
353
354 function addDefaultEncoderGlobalParams (options: {
355 command: FfmpegCommand
356 }) {
357 const { command } = options
358
359 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
360 command.outputOption('-max_muxing_queue_size 1024')
361 // strip all metadata
362 .outputOption('-map_metadata -1')
363 // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
364 .outputOption('-b_strategy 1')
365 // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
366 .outputOption('-bf 16')
367 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
368 .outputOption('-pix_fmt yuv420p')
369 }
370
371 function addDefaultEncoderParams (options: {
372 command: FfmpegCommand
373 encoder: 'libx264' | string
374 streamNum?: number
375 fps?: number
376 }) {
377 const { command, encoder, fps, streamNum } = options
378
379 if (encoder === 'libx264') {
380 // 3.1 is the minimal resource allocation for our highest supported resolution
381 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
382
383 if (fps) {
384 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
385 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
386 // https://superuser.com/a/908325
387 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
388 }
389 }
390 }
391
392 function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
393 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
394 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
395 command.outputOption('-hls_flags delete_segments+independent_segments')
396 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
397 command.outputOption('-master_pl_name ' + masterPlaylistName)
398 command.outputOption(`-f hls`)
399
400 command.output(join(outPath, '%v.m3u8'))
401 }
402
403 // ---------------------------------------------------------------------------
404 // Transcode VOD command builders
405 // ---------------------------------------------------------------------------
406
407 async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) {
408 let fps = await getVideoFileFPS(options.inputPath)
409 fps = computeFPS(fps, options.resolution)
410
411 let scaleFilterValue: string
412
413 if (options.resolution !== undefined) {
414 scaleFilterValue = options.isPortraitMode === true
415 ? `w=${options.resolution}:h=-2`
416 : `w=-2:h=${options.resolution}`
417 }
418
419 command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue })
420
421 return command
422 }
423
424 async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
425 command = command.loop(undefined)
426
427 const scaleFilterValue = getScaleCleanerValue()
428 command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
429
430 command.outputOption('-preset:v veryfast')
431
432 command = command.input(options.audioPath)
433 .outputOption('-tune stillimage')
434 .outputOption('-shortest')
435
436 return command
437 }
438
439 function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
440 command = presetOnlyAudio(command)
441
442 return command
443 }
444
445 function buildQuickTranscodeCommand (command: FfmpegCommand) {
446 command = presetCopy(command)
447
448 command = command.outputOption('-map_metadata -1') // strip all metadata
449 .outputOption('-movflags faststart')
450
451 return command
452 }
453
454 function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
455 return command.outputOption('-hls_time 4')
456 .outputOption('-hls_list_size 0')
457 .outputOption('-hls_playlist_type vod')
458 .outputOption('-hls_segment_filename ' + outputPath)
459 .outputOption('-hls_segment_type fmp4')
460 .outputOption('-f hls')
461 .outputOption('-hls_flags single_file')
462 }
463
464 async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
465 const videoPath = getHLSVideoPath(options)
466
467 if (options.copyCodecs) command = presetCopy(command)
468 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
469 else command = await buildx264VODCommand(command, options)
470
471 addCommonHLSVODCommandOptions(command, videoPath)
472
473 return command
474 }
475
476 function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
477 const videoPath = getHLSVideoPath(options)
478
479 command.outputOption('-c copy')
480
481 if (options.isAAC) {
482 // Required for example when copying an AAC stream from an MPEG-TS
483 // Since it's a bitstream filter, we don't need to reencode the audio
484 command.outputOption('-bsf:a aac_adtstoasc')
485 }
486
487 addCommonHLSVODCommandOptions(command, videoPath)
488
489 return command
490 }
491
492 async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
493 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
494
495 const fileContent = await readFile(options.outputPath)
496
497 const videoFileName = options.hlsPlaylist.videoFilename
498 const videoFilePath = getHLSVideoPath(options)
499
500 // Fix wrong mapping with some ffmpeg versions
501 const newContent = fileContent.toString()
502 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
503
504 await writeFile(options.outputPath, newContent)
505 }
506
507 function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
508 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
509 }
510
511 // ---------------------------------------------------------------------------
512 // Transcoding presets
513 // ---------------------------------------------------------------------------
514
515 // Run encoder builder depending on available encoders
516 // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
517 // If the default one does not exist, check the next encoder
518 async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
519 streamType: 'video' | 'audio'
520 input: string
521
522 availableEncoders: AvailableEncoders
523 profile: string
524
525 videoType: 'vod' | 'live'
526 }) {
527 const { availableEncoders, profile, streamType, videoType } = options
528
529 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
530 const encoders = availableEncoders.available[videoType]
531
532 for (const encoder of encodersToTry) {
533 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
534 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder)
535 continue
536 }
537
538 if (!encoders[encoder]) {
539 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder)
540 continue
541 }
542
543 // An object containing available profiles for this encoder
544 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
545 let builder = builderProfiles[profile]
546
547 if (!builder) {
548 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder)
549 builder = builderProfiles.default
550
551 if (!builder) {
552 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder)
553 continue
554 }
555 }
556
557 const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]))
558
559 return {
560 result,
561
562 // If we don't have output options, then copy the input stream
563 encoder: result.copy === true
564 ? 'copy'
565 : encoder
566 }
567 }
568
569 return null
570 }
571
572 async function presetVideo (options: {
573 command: FfmpegCommand
574 input: string
575 transcodeOptions: TranscodeOptions
576 fps?: number
577 scaleFilterValue?: string
578 }) {
579 const { command, input, transcodeOptions, fps, scaleFilterValue } = options
580
581 let localCommand = command
582 .format('mp4')
583 .outputOption('-movflags faststart')
584
585 addDefaultEncoderGlobalParams({ command })
586
587 const probe = await ffprobePromise(input)
588
589 // Audio encoder
590 const parsedAudio = await getAudioStream(input, probe)
591 const bitrate = await getVideoFileBitrate(input, probe)
592 const { ratio } = await getVideoFileResolution(input, probe)
593
594 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
595
596 if (!parsedAudio.audioStream) {
597 localCommand = localCommand.noAudio()
598 streamsToProcess = [ 'video' ]
599 }
600
601 for (const streamType of streamsToProcess) {
602 const { profile, resolution, availableEncoders } = transcodeOptions
603
604 const builderResult = await getEncoderBuilderResult({
605 streamType,
606 input,
607 resolution,
608 availableEncoders,
609 profile,
610 fps,
611 inputBitrate: bitrate,
612 inputRatio: ratio,
613 videoType: 'vod' as 'vod'
614 })
615
616 if (!builderResult) {
617 throw new Error('No available encoder found for stream ' + streamType)
618 }
619
620 logger.debug(
621 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
622 builderResult.encoder, streamType, input, profile, builderResult
623 )
624
625 if (streamType === 'video') {
626 localCommand.videoCodec(builderResult.encoder)
627
628 if (scaleFilterValue) {
629 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
630 }
631 } else if (streamType === 'audio') {
632 localCommand.audioCodec(builderResult.encoder)
633 }
634
635 applyEncoderOptions(localCommand, builderResult.result)
636 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
637 }
638
639 return localCommand
640 }
641
642 function presetCopy (command: FfmpegCommand): FfmpegCommand {
643 return command
644 .format('mp4')
645 .videoCodec('copy')
646 .audioCodec('copy')
647 }
648
649 function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
650 return command
651 .format('mp4')
652 .audioCodec('copy')
653 .noVideo()
654 }
655
656 function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
657 return command
658 .inputOptions(options.inputOptions ?? [])
659 .outputOptions(options.outputOptions ?? [])
660 }
661
662 function getScaleFilter (options: EncoderOptions): string {
663 if (options.scaleFilter) return options.scaleFilter.name
664
665 return 'scale'
666 }
667
668 // ---------------------------------------------------------------------------
669 // Utils
670 // ---------------------------------------------------------------------------
671
672 function getFFmpeg (input: string, type: 'live' | 'vod') {
673 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
674 const command = ffmpeg(input, {
675 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
676 cwd: CONFIG.STORAGE.TMP_DIR
677 })
678
679 const threads = type === 'live'
680 ? CONFIG.LIVE.TRANSCODING.THREADS
681 : CONFIG.TRANSCODING.THREADS
682
683 if (threads > 0) {
684 // If we don't set any threads ffmpeg will chose automatically
685 command.outputOption('-threads ' + threads)
686 }
687
688 return command
689 }
690
691 function getFFmpegVersion () {
692 return new Promise<string>((res, rej) => {
693 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
694 if (err) return rej(err)
695 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
696
697 return execPromise(`${ffmpegPath} -version`)
698 .then(stdout => {
699 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
700 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
701
702 // Fix ffmpeg version that does not include patch version (4.4 for example)
703 let version = parsed[1]
704 if (version.match(/^\d+\.\d+$/)) {
705 version += '.0'
706 }
707
708 return res(version)
709 })
710 .catch(err => rej(err))
711 })
712 })
713 }
714
715 async function runCommand (options: {
716 command: FfmpegCommand
717 silent?: boolean // false
718 job?: Job
719 }) {
720 const { command, silent = false, job } = options
721
722 return new Promise<void>((res, rej) => {
723 let shellCommand: string
724
725 command.on('start', cmdline => { shellCommand = cmdline })
726
727 command.on('error', (err, stdout, stderr) => {
728 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr })
729
730 rej(err)
731 })
732
733 command.on('end', (stdout, stderr) => {
734 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand })
735
736 res()
737 })
738
739 if (job) {
740 command.on('progress', progress => {
741 if (!progress.percent) return
742
743 job.progress(Math.round(progress.percent))
744 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err }))
745 })
746 }
747
748 command.run()
749 })
750 }
751
752 // Avoid "height not divisible by 2" error
753 function getScaleCleanerValue () {
754 return 'trunc(iw/2)*2:trunc(ih/2)*2'
755 }
756
757 // ---------------------------------------------------------------------------
758
759 export {
760 getLiveTranscodingCommand,
761 getLiveMuxingCommand,
762 buildStreamSuffix,
763 convertWebPToJPG,
764 processGIF,
765 generateImageFromVideoFile,
766 TranscodeOptions,
767 TranscodeOptionsType,
768 transcode,
769 runCommand,
770 getFFmpegVersion,
771
772 resetSupportedEncoders,
773
774 // builders
775 buildx264VODCommand
776 }