ffmpeg-utils.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import * as ffmpeg from 'fluent-ffmpeg'
  2. import { join } from 'path'
  3. import { VideoResolution, getTargetBitrate } from '../../shared/models/videos'
  4. import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers'
  5. import { processImage } from './image-utils'
  6. import { logger } from './logger'
  7. import { checkFFmpegEncoders } from '../initializers/checker-before-init'
  8. import { remove } from 'fs-extra'
  9. function computeResolutionsToTranscode (videoFileHeight: number) {
  10. const resolutionsEnabled: number[] = []
  11. const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
  12. // Put in the order we want to proceed jobs
  13. const resolutions = [
  14. VideoResolution.H_480P,
  15. VideoResolution.H_360P,
  16. VideoResolution.H_720P,
  17. VideoResolution.H_240P,
  18. VideoResolution.H_1080P
  19. ]
  20. for (const resolution of resolutions) {
  21. if (configResolutions[ resolution + 'p' ] === true && videoFileHeight > resolution) {
  22. resolutionsEnabled.push(resolution)
  23. }
  24. }
  25. return resolutionsEnabled
  26. }
  27. async function getVideoFileResolution (path: string) {
  28. const videoStream = await getVideoFileStream(path)
  29. return {
  30. videoFileResolution: Math.min(videoStream.height, videoStream.width),
  31. isPortraitMode: videoStream.height > videoStream.width
  32. }
  33. }
  34. async function getVideoFileFPS (path: string) {
  35. const videoStream = await getVideoFileStream(path)
  36. for (const key of [ 'r_frame_rate' , 'avg_frame_rate' ]) {
  37. const valuesText: string = videoStream[key]
  38. if (!valuesText) continue
  39. const [ frames, seconds ] = valuesText.split('/')
  40. if (!frames || !seconds) continue
  41. const result = parseInt(frames, 10) / parseInt(seconds, 10)
  42. if (result > 0) return Math.round(result)
  43. }
  44. return 0
  45. }
  46. async function getVideoFileBitrate (path: string) {
  47. return new Promise<number>((res, rej) => {
  48. ffmpeg.ffprobe(path, (err, metadata) => {
  49. if (err) return rej(err)
  50. return res(metadata.format.bit_rate)
  51. })
  52. })
  53. }
  54. function getDurationFromVideoFile (path: string) {
  55. return new Promise<number>((res, rej) => {
  56. ffmpeg.ffprobe(path, (err, metadata) => {
  57. if (err) return rej(err)
  58. return res(Math.floor(metadata.format.duration))
  59. })
  60. })
  61. }
  62. async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
  63. const pendingImageName = 'pending-' + imageName
  64. const options = {
  65. filename: pendingImageName,
  66. count: 1,
  67. folder
  68. }
  69. const pendingImagePath = join(folder, pendingImageName)
  70. try {
  71. await new Promise<string>((res, rej) => {
  72. ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
  73. .on('error', rej)
  74. .on('end', () => res(imageName))
  75. .thumbnail(options)
  76. })
  77. const destination = join(folder, imageName)
  78. await processImage({ path: pendingImagePath }, destination, size)
  79. } catch (err) {
  80. logger.error('Cannot generate image from video %s.', fromPath, { err })
  81. try {
  82. await remove(pendingImagePath)
  83. } catch (err) {
  84. logger.debug('Cannot remove pending image path after generation error.', { err })
  85. }
  86. }
  87. }
  88. type TranscodeOptions = {
  89. inputPath: string
  90. outputPath: string
  91. resolution?: VideoResolution
  92. isPortraitMode?: boolean
  93. }
  94. function transcode (options: TranscodeOptions) {
  95. return new Promise<void>(async (res, rej) => {
  96. let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
  97. .output(options.outputPath)
  98. .preset(standard)
  99. if (CONFIG.TRANSCODING.THREADS > 0) {
  100. // if we don't set any threads ffmpeg will chose automatically
  101. command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
  102. }
  103. let fps = await getVideoFileFPS(options.inputPath)
  104. if (options.resolution !== undefined) {
  105. // '?x720' or '720x?' for example
  106. const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
  107. command = command.size(size)
  108. // On small/medium resolutions, limit FPS
  109. if (
  110. options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
  111. fps > VIDEO_TRANSCODING_FPS.AVERAGE
  112. ) {
  113. fps = VIDEO_TRANSCODING_FPS.AVERAGE
  114. }
  115. }
  116. if (fps) {
  117. // Hard FPS limits
  118. if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX
  119. else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
  120. command = command.withFPS(fps)
  121. }
  122. // Constrained Encoding (VBV)
  123. // https://slhck.info/video/2017/03/01/rate-control.html
  124. // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
  125. const targetBitrate = getTargetBitrate(options.resolution, fps, VIDEO_TRANSCODING_FPS)
  126. command.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`])
  127. command
  128. .on('error', (err, stdout, stderr) => {
  129. logger.error('Error in transcoding job.', { stdout, stderr })
  130. return rej(err)
  131. })
  132. .on('end', res)
  133. .run()
  134. })
  135. }
  136. // ---------------------------------------------------------------------------
  137. export {
  138. getVideoFileResolution,
  139. getDurationFromVideoFile,
  140. generateImageFromVideoFile,
  141. transcode,
  142. getVideoFileFPS,
  143. computeResolutionsToTranscode,
  144. audio,
  145. getVideoFileBitrate
  146. }
  147. // ---------------------------------------------------------------------------
  148. function getVideoFileStream (path: string) {
  149. return new Promise<any>((res, rej) => {
  150. ffmpeg.ffprobe(path, (err, metadata) => {
  151. if (err) return rej(err)
  152. const videoStream = metadata.streams.find(s => s.codec_type === 'video')
  153. if (!videoStream) throw new Error('Cannot find video stream of ' + path)
  154. return res(videoStream)
  155. })
  156. })
  157. }
  158. /**
  159. * A slightly customised version of the 'veryfast' x264 preset
  160. *
  161. * The veryfast preset is right in the sweet spot of performance
  162. * and quality. Superfast and ultrafast will give you better
  163. * performance, but then quality is noticeably worse.
  164. */
  165. function veryfast (_ffmpeg) {
  166. _ffmpeg
  167. .preset(standard)
  168. .outputOption('-preset:v veryfast')
  169. .outputOption(['--aq-mode=2', '--aq-strength=1.3'])
  170. /*
  171. MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
  172. Our target situation is closer to a livestream than a stream,
  173. since we want to reduce as much a possible the encoding burden,
  174. altough not to the point of a livestream where there is a hard
  175. constraint on the frames per second to be encoded.
  176. why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
  177. Make up for most of the loss of grain and macroblocking
  178. with less computing power.
  179. */
  180. }
  181. /**
  182. * A preset optimised for a stillimage audio video
  183. */
  184. function audio (_ffmpeg) {
  185. _ffmpeg
  186. .preset(veryfast)
  187. .outputOption('-tune stillimage')
  188. }
  189. /**
  190. * A toolbox to play with audio
  191. */
  192. namespace audio {
  193. export const get = (_ffmpeg, pos: number | string = 0) => {
  194. // without position, ffprobe considers the last input only
  195. // we make it consider the first input only
  196. // if you pass a file path to pos, then ffprobe acts on that file directly
  197. return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
  198. _ffmpeg.ffprobe(pos, (err,data) => {
  199. if (err) return rej(err)
  200. if ('streams' in data) {
  201. const audioStream = data['streams'].find(stream => stream['codec_type'] === 'audio')
  202. if (audioStream) {
  203. return res({
  204. absolutePath: data.format.filename,
  205. audioStream
  206. })
  207. }
  208. }
  209. return res({ absolutePath: data.format.filename })
  210. })
  211. })
  212. }
  213. export namespace bitrate {
  214. const baseKbitrate = 384
  215. const toBits = (kbits: number): number => { return kbits * 8000 }
  216. export const aac = (bitrate: number): number => {
  217. switch (true) {
  218. case bitrate > toBits(baseKbitrate):
  219. return baseKbitrate
  220. default:
  221. return -1 // we interpret it as a signal to copy the audio stream as is
  222. }
  223. }
  224. export const mp3 = (bitrate: number): number => {
  225. /*
  226. a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
  227. That's why, when using aac, we can go to lower kbit/sec. The equivalences
  228. made here are not made to be accurate, especially with good mp3 encoders.
  229. */
  230. switch (true) {
  231. case bitrate <= toBits(192):
  232. return 128
  233. case bitrate <= toBits(384):
  234. return 256
  235. default:
  236. return baseKbitrate
  237. }
  238. }
  239. }
  240. }
  241. /**
  242. * Standard profile, with variable bitrate audio and faststart.
  243. *
  244. * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
  245. * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
  246. */
  247. async function standard (_ffmpeg) {
  248. let localFfmpeg = _ffmpeg
  249. .format('mp4')
  250. .videoCodec('libx264')
  251. .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution
  252. .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it
  253. .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
  254. .outputOption('-map_metadata -1') // strip all metadata
  255. .outputOption('-movflags faststart')
  256. const _audio = await audio.get(localFfmpeg)
  257. if (!_audio.audioStream) {
  258. return localFfmpeg.noAudio()
  259. }
  260. // we favor VBR, if a good AAC encoder is available
  261. if ((await checkFFmpegEncoders()).get('libfdk_aac')) {
  262. return localFfmpeg
  263. .audioCodec('libfdk_aac')
  264. .audioQuality(5)
  265. }
  266. // we try to reduce the ceiling bitrate by making rough correspondances of bitrates
  267. // of course this is far from perfect, but it might save some space in the end
  268. const audioCodecName = _audio.audioStream['codec_name']
  269. let bitrate: number
  270. if (audio.bitrate[audioCodecName]) {
  271. bitrate = audio.bitrate[audioCodecName](_audio.audioStream['bit_rate'])
  272. if (bitrate === -1) return localFfmpeg.audioCodec('copy')
  273. }
  274. if (bitrate !== undefined) return localFfmpeg.audioBitrate(bitrate)
  275. return localFfmpeg
  276. }