Add audio only transcoding tests
[oweals/peertube.git] / server / helpers / ffmpeg-utils.ts
1 import * as ffmpeg from 'fluent-ffmpeg'
2 import { dirname, join } from 'path'
3 import { getTargetBitrate, getMaxBitrate, VideoResolution } from '../../shared/models/videos'
4 import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
5 import { processImage } from './image-utils'
6 import { logger } from './logger'
7 import { checkFFmpegEncoders } from '../initializers/checker-before-init'
8 import { readFile, remove, writeFile } from 'fs-extra'
9 import { CONFIG } from '../initializers/config'
10
11 function computeResolutionsToTranscode (videoFileHeight: number) {
12   const resolutionsEnabled: number[] = []
13   const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
14
15   // Put in the order we want to proceed jobs
16   const resolutions = [
17     VideoResolution.H_NOVIDEO,
18     VideoResolution.H_480P,
19     VideoResolution.H_360P,
20     VideoResolution.H_720P,
21     VideoResolution.H_240P,
22     VideoResolution.H_1080P,
23     VideoResolution.H_4K
24   ]
25
26   for (const resolution of resolutions) {
27     if (configResolutions[ resolution + 'p' ] === true && videoFileHeight > resolution) {
28       resolutionsEnabled.push(resolution)
29     }
30   }
31
32   return resolutionsEnabled
33 }
34
35 async function getVideoFileSize (path: string) {
36   const videoStream = await getVideoStreamFromFile(path)
37
38   return videoStream === null
39     ? { width: 0, height: 0 }
40     : { width: videoStream.width, height: videoStream.height }
41 }
42
43 async function getVideoFileResolution (path: string) {
44   const size = await getVideoFileSize(path)
45
46   return {
47     videoFileResolution: Math.min(size.height, size.width),
48     isPortraitMode: size.height > size.width
49   }
50 }
51
52 async function getVideoFileFPS (path: string) {
53   const videoStream = await getVideoStreamFromFile(path)
54   if (videoStream === null) return 0
55
56   for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
57     const valuesText: string = videoStream[ key ]
58     if (!valuesText) continue
59
60     const [ frames, seconds ] = valuesText.split('/')
61     if (!frames || !seconds) continue
62
63     const result = parseInt(frames, 10) / parseInt(seconds, 10)
64     if (result > 0) return Math.round(result)
65   }
66
67   return 0
68 }
69
70 async function getVideoFileBitrate (path: string) {
71   return new Promise<number>((res, rej) => {
72     ffmpeg.ffprobe(path, (err, metadata) => {
73       if (err) return rej(err)
74
75       return res(metadata.format.bit_rate)
76     })
77   })
78 }
79
80 function getDurationFromVideoFile (path: string) {
81   return new Promise<number>((res, rej) => {
82     ffmpeg.ffprobe(path, (err, metadata) => {
83       if (err) return rej(err)
84
85       return res(Math.floor(metadata.format.duration))
86     })
87   })
88 }
89
90 async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
91   const pendingImageName = 'pending-' + imageName
92
93   const options = {
94     filename: pendingImageName,
95     count: 1,
96     folder
97   }
98
99   const pendingImagePath = join(folder, pendingImageName)
100
101   try {
102     await new Promise<string>((res, rej) => {
103       ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
104         .on('error', rej)
105         .on('end', () => res(imageName))
106         .thumbnail(options)
107     })
108
109     const destination = join(folder, imageName)
110     await processImage(pendingImagePath, destination, size)
111   } catch (err) {
112     logger.error('Cannot generate image from video %s.', fromPath, { err })
113
114     try {
115       await remove(pendingImagePath)
116     } catch (err) {
117       logger.debug('Cannot remove pending image path after generation error.', { err })
118     }
119   }
120 }
121
122 type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
123
124 interface BaseTranscodeOptions {
125   type: TranscodeOptionsType
126   inputPath: string
127   outputPath: string
128   resolution: VideoResolution
129   isPortraitMode?: boolean
130 }
131
132 interface HLSTranscodeOptions extends BaseTranscodeOptions {
133   type: 'hls'
134   copyCodecs: boolean
135   hlsPlaylist: {
136     videoFilename: string
137   }
138 }
139
140 interface QuickTranscodeOptions extends BaseTranscodeOptions {
141   type: 'quick-transcode'
142 }
143
144 interface VideoTranscodeOptions extends BaseTranscodeOptions {
145   type: 'video'
146 }
147
148 interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
149   type: 'merge-audio'
150   audioPath: string
151 }
152
153 interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
154   type: 'only-audio'
155 }
156
157 type TranscodeOptions = HLSTranscodeOptions
158   | VideoTranscodeOptions
159   | MergeAudioTranscodeOptions
160   | OnlyAudioTranscodeOptions
161   | QuickTranscodeOptions
162
163 function transcode (options: TranscodeOptions) {
164   return new Promise<void>(async (res, rej) => {
165     try {
166       let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
167         .output(options.outputPath)
168
169       if (options.type === 'quick-transcode') {
170         command = await buildQuickTranscodeCommand(command)
171       } else if (options.type === 'hls') {
172         command = await buildHLSCommand(command, options)
173       } else if (options.type === 'merge-audio') {
174         command = await buildAudioMergeCommand(command, options)
175       } else if (options.type === 'only-audio') {
176         command = await buildOnlyAudioCommand(command, options)
177       } else {
178         command = await buildx264Command(command, options)
179       }
180
181       if (CONFIG.TRANSCODING.THREADS > 0) {
182         // if we don't set any threads ffmpeg will chose automatically
183         command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
184       }
185
186       command
187         .on('error', (err, stdout, stderr) => {
188           logger.error('Error in transcoding job.', { stdout, stderr })
189           return rej(err)
190         })
191         .on('end', () => {
192           return fixHLSPlaylistIfNeeded(options)
193             .then(() => res())
194             .catch(err => rej(err))
195         })
196         .run()
197     } catch (err) {
198       return rej(err)
199     }
200   })
201 }
202
203 async function canDoQuickTranscode (path: string): Promise<boolean> {
204   // NOTE: This could be optimized by running ffprobe only once (but it runs fast anyway)
205   const videoStream = await getVideoStreamFromFile(path)
206   const parsedAudio = await audio.get(path)
207   const fps = await getVideoFileFPS(path)
208   const bitRate = await getVideoFileBitrate(path)
209   const resolution = await getVideoFileResolution(path)
210
211   // check video params
212   if (videoStream == null) return false
213   if (videoStream[ 'codec_name' ] !== 'h264') return false
214   if (videoStream[ 'pix_fmt' ] !== 'yuv420p') return false
215   if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
216   if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false
217
218   // check audio params (if audio stream exists)
219   if (parsedAudio.audioStream) {
220     if (parsedAudio.audioStream[ 'codec_name' ] !== 'aac') return false
221
222     const maxAudioBitrate = audio.bitrate[ 'aac' ](parsedAudio.audioStream[ 'bit_rate' ])
223     if (maxAudioBitrate !== -1 && parsedAudio.audioStream[ 'bit_rate' ] > maxAudioBitrate) return false
224   }
225
226   return true
227 }
228
229 // ---------------------------------------------------------------------------
230
231 export {
232   getVideoFileSize,
233   getVideoFileResolution,
234   getDurationFromVideoFile,
235   generateImageFromVideoFile,
236   TranscodeOptions,
237   TranscodeOptionsType,
238   transcode,
239   getVideoFileFPS,
240   computeResolutionsToTranscode,
241   audio,
242   getVideoFileBitrate,
243   canDoQuickTranscode
244 }
245
246 // ---------------------------------------------------------------------------
247
248 async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
249   let fps = await getVideoFileFPS(options.inputPath)
250   // On small/medium resolutions, limit FPS
251   if (
252     options.resolution !== undefined &&
253     options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
254     fps > VIDEO_TRANSCODING_FPS.AVERAGE
255   ) {
256     fps = VIDEO_TRANSCODING_FPS.AVERAGE
257   }
258
259   command = await presetH264(command, options.inputPath, options.resolution, fps)
260
261   if (options.resolution !== undefined) {
262     // '?x720' or '720x?' for example
263     const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
264     command = command.size(size)
265   }
266
267   if (fps) {
268     // Hard FPS limits
269     if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX
270     else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
271
272     command = command.withFPS(fps)
273   }
274
275   return command
276 }
277
278 async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
279   command = command.loop(undefined)
280
281   command = await presetH264VeryFast(command, options.audioPath, options.resolution)
282
283   command = command.input(options.audioPath)
284                    .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
285                    .outputOption('-tune stillimage')
286                    .outputOption('-shortest')
287
288   return command
289 }
290
291 async function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, options: OnlyAudioTranscodeOptions) {
292   command = await presetOnlyAudio(command)
293
294   return command
295 }
296
297 async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
298   command = await presetCopy(command)
299
300   command = command.outputOption('-map_metadata -1') // strip all metadata
301                    .outputOption('-movflags faststart')
302
303   return command
304 }
305
306 async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
307   const videoPath = getHLSVideoPath(options)
308
309   if (options.copyCodecs) command = await presetCopy(command)
310   else command = await buildx264Command(command, options)
311
312   command = command.outputOption('-hls_time 4')
313                    .outputOption('-hls_list_size 0')
314                    .outputOption('-hls_playlist_type vod')
315                    .outputOption('-hls_segment_filename ' + videoPath)
316                    .outputOption('-hls_segment_type fmp4')
317                    .outputOption('-f hls')
318                    .outputOption('-hls_flags single_file')
319
320   return command
321 }
322
323 function getHLSVideoPath (options: HLSTranscodeOptions) {
324   return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
325 }
326
327 async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
328   if (options.type !== 'hls') return
329
330   const fileContent = await readFile(options.outputPath)
331
332   const videoFileName = options.hlsPlaylist.videoFilename
333   const videoFilePath = getHLSVideoPath(options)
334
335   // Fix wrong mapping with some ffmpeg versions
336   const newContent = fileContent.toString()
337                                 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
338
339   await writeFile(options.outputPath, newContent)
340 }
341
342 function getVideoStreamFromFile (path: string) {
343   return new Promise<any>((res, rej) => {
344     ffmpeg.ffprobe(path, (err, metadata) => {
345       if (err) return rej(err)
346
347       const videoStream = metadata.streams.find(s => s.codec_type === 'video')
348       return res(videoStream || null)
349     })
350   })
351 }
352
353 /**
354  * A slightly customised version of the 'veryfast' x264 preset
355  *
356  * The veryfast preset is right in the sweet spot of performance
357  * and quality. Superfast and ultrafast will give you better
358  * performance, but then quality is noticeably worse.
359  */
360 async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
361   let localCommand = await presetH264(command, input, resolution, fps)
362
363   localCommand = localCommand.outputOption('-preset:v veryfast')
364
365   /*
366   MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
367   Our target situation is closer to a livestream than a stream,
368   since we want to reduce as much a possible the encoding burden,
369   although not to the point of a livestream where there is a hard
370   constraint on the frames per second to be encoded.
371   */
372
373   return localCommand
374 }
375
376 /**
377  * A toolbox to play with audio
378  */
379 namespace audio {
380   export const get = (videoPath: string) => {
381     // without position, ffprobe considers the last input only
382     // we make it consider the first input only
383     // if you pass a file path to pos, then ffprobe acts on that file directly
384     return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
385
386       function parseFfprobe (err: any, data: ffmpeg.FfprobeData) {
387         if (err) return rej(err)
388
389         if ('streams' in data) {
390           const audioStream = data.streams.find(stream => stream[ 'codec_type' ] === 'audio')
391           if (audioStream) {
392             return res({
393               absolutePath: data.format.filename,
394               audioStream
395             })
396           }
397         }
398
399         return res({ absolutePath: data.format.filename })
400       }
401
402       return ffmpeg.ffprobe(videoPath, parseFfprobe)
403     })
404   }
405
406   export namespace bitrate {
407     const baseKbitrate = 384
408
409     const toBits = (kbits: number) => kbits * 8000
410
411     export const aac = (bitrate: number): number => {
412       switch (true) {
413         case bitrate > toBits(baseKbitrate):
414           return baseKbitrate
415
416         default:
417           return -1 // we interpret it as a signal to copy the audio stream as is
418       }
419     }
420
421     export const mp3 = (bitrate: number): number => {
422       /*
423       a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
424       That's why, when using aac, we can go to lower kbit/sec. The equivalences
425       made here are not made to be accurate, especially with good mp3 encoders.
426       */
427       switch (true) {
428         case bitrate <= toBits(192):
429           return 128
430
431         case bitrate <= toBits(384):
432           return 256
433
434         default:
435           return baseKbitrate
436       }
437     }
438   }
439 }
440
441 /**
442  * Standard profile, with variable bitrate audio and faststart.
443  *
444  * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
445  * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
446  */
447 async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
448   let localCommand = command
449     .format('mp4')
450     .videoCodec('libx264')
451     .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution
452     .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it
453     .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
454     .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
455     .outputOption('-map_metadata -1') // strip all metadata
456     .outputOption('-movflags faststart')
457
458   const parsedAudio = await audio.get(input)
459
460   if (!parsedAudio.audioStream) {
461     localCommand = localCommand.noAudio()
462   } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available
463     localCommand = localCommand
464       .audioCodec('libfdk_aac')
465       .audioQuality(5)
466   } else {
467     // we try to reduce the ceiling bitrate by making rough matches of bitrates
468     // of course this is far from perfect, but it might save some space in the end
469     localCommand = localCommand.audioCodec('aac')
470
471     const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
472
473     if (audio.bitrate[ audioCodecName ]) {
474       const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
475       if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
476     }
477   }
478
479   if (fps) {
480     // Constrained Encoding (VBV)
481     // https://slhck.info/video/2017/03/01/rate-control.html
482     // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
483     const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
484     localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
485
486     // Keyframe interval of 2 seconds for faster seeking and resolution switching.
487     // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
488     // https://superuser.com/a/908325
489     localCommand = localCommand.outputOption(`-g ${fps * 2}`)
490   }
491
492   return localCommand
493 }
494
495 async function presetCopy (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.FfmpegCommand> {
496   return command
497     .format('mp4')
498     .videoCodec('copy')
499     .audioCodec('copy')
500 }
501
502 async function presetOnlyAudio (command: ffmpeg.FfmpegCommand): Promise<ffmpeg.FfmpegCommand> {
503   return command
504     .format('mp4')
505     .audioCodec('copy')
506     .noVideo()
507 }