ffmpeg-utils.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import { Job } from 'bull'
  2. import * as ffmpeg from 'fluent-ffmpeg'
  3. import { readFile, remove, writeFile } from 'fs-extra'
  4. import { dirname, join } from 'path'
  5. import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_ENCODERS } from '@server/initializers/constants'
  6. import { VideoResolution } from '../../shared/models/videos'
  7. import { checkFFmpegEncoders } from '../initializers/checker-before-init'
  8. import { CONFIG } from '../initializers/config'
  9. import { computeFPS, getAudioStream, getVideoFileFPS } from './ffprobe-utils'
  10. import { processImage } from './image-utils'
  11. import { logger } from './logger'
  12. /**
  13. *
  14. * Functions that run transcoding/muxing ffmpeg processes
  15. * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
  16. *
  17. */
  18. // ---------------------------------------------------------------------------
  19. // Encoder options
  20. // ---------------------------------------------------------------------------
  21. // Options builders
  22. export type EncoderOptionsBuilder = (params: {
  23. input: string
  24. resolution: VideoResolution
  25. fps?: number
  26. streamNum?: number
  27. }) => Promise<EncoderOptions> | EncoderOptions
  28. // Options types
  29. export interface EncoderOptions {
  30. copy?: boolean
  31. outputOptions: string[]
  32. }
  33. // All our encoders
  34. export interface EncoderProfile <T> {
  35. [ profile: string ]: T
  36. default: T
  37. }
  38. export type AvailableEncoders = {
  39. [ id in 'live' | 'vod' ]: {
  40. [ encoder in 'libx264' | 'aac' | 'libfdk_aac' ]?: EncoderProfile<EncoderOptionsBuilder>
  41. }
  42. }
  43. // ---------------------------------------------------------------------------
  44. // Image manipulation
  45. // ---------------------------------------------------------------------------
  46. function convertWebPToJPG (path: string, destination: string): Promise<void> {
  47. const command = ffmpeg(path)
  48. .output(destination)
  49. return runCommand(command)
  50. }
  51. function processGIF (
  52. path: string,
  53. destination: string,
  54. newSize: { width: number, height: number }
  55. ): Promise<void> {
  56. const command = ffmpeg(path)
  57. .fps(20)
  58. .size(`${newSize.width}x${newSize.height}`)
  59. .output(destination)
  60. return runCommand(command)
  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(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. // ---------------------------------------------------------------------------
  89. // Transcode meta function
  90. // ---------------------------------------------------------------------------
  91. type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
  92. interface BaseTranscodeOptions {
  93. type: TranscodeOptionsType
  94. inputPath: string
  95. outputPath: string
  96. availableEncoders: AvailableEncoders
  97. profile: string
  98. resolution: VideoResolution
  99. isPortraitMode?: boolean
  100. job?: Job
  101. }
  102. interface HLSTranscodeOptions extends BaseTranscodeOptions {
  103. type: 'hls'
  104. copyCodecs: boolean
  105. hlsPlaylist: {
  106. videoFilename: string
  107. }
  108. }
  109. interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
  110. type: 'hls-from-ts'
  111. isAAC: boolean
  112. hlsPlaylist: {
  113. videoFilename: string
  114. }
  115. }
  116. interface QuickTranscodeOptions extends BaseTranscodeOptions {
  117. type: 'quick-transcode'
  118. }
  119. interface VideoTranscodeOptions extends BaseTranscodeOptions {
  120. type: 'video'
  121. }
  122. interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
  123. type: 'merge-audio'
  124. audioPath: string
  125. }
  126. interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
  127. type: 'only-audio'
  128. }
  129. type TranscodeOptions =
  130. HLSTranscodeOptions
  131. | HLSFromTSTranscodeOptions
  132. | VideoTranscodeOptions
  133. | MergeAudioTranscodeOptions
  134. | OnlyAudioTranscodeOptions
  135. | QuickTranscodeOptions
  136. const builders: {
  137. [ type in TranscodeOptionsType ]: (c: ffmpeg.FfmpegCommand, o?: TranscodeOptions) => Promise<ffmpeg.FfmpegCommand> | ffmpeg.FfmpegCommand
  138. } = {
  139. 'quick-transcode': buildQuickTranscodeCommand,
  140. 'hls': buildHLSVODCommand,
  141. 'hls-from-ts': buildHLSVODFromTSCommand,
  142. 'merge-audio': buildAudioMergeCommand,
  143. 'only-audio': buildOnlyAudioCommand,
  144. 'video': buildx264VODCommand
  145. }
  146. async function transcode (options: TranscodeOptions) {
  147. logger.debug('Will run transcode.', { options })
  148. let command = getFFmpeg(options.inputPath, 'vod')
  149. .output(options.outputPath)
  150. command = await builders[options.type](command, options)
  151. await runCommand(command, options.job)
  152. await fixHLSPlaylistIfNeeded(options)
  153. }
  154. // ---------------------------------------------------------------------------
  155. // Live muxing/transcoding functions
  156. // ---------------------------------------------------------------------------
  157. async function getLiveTranscodingCommand (options: {
  158. rtmpUrl: string
  159. outPath: string
  160. resolutions: number[]
  161. fps: number
  162. availableEncoders: AvailableEncoders
  163. profile: string
  164. }) {
  165. const { rtmpUrl, outPath, resolutions, fps, availableEncoders, profile } = options
  166. const input = rtmpUrl
  167. const command = getFFmpeg(input, 'live')
  168. const varStreamMap: string[] = []
  169. command.complexFilter([
  170. {
  171. inputs: '[v:0]',
  172. filter: 'split',
  173. options: resolutions.length,
  174. outputs: resolutions.map(r => `vtemp${r}`)
  175. },
  176. ...resolutions.map(r => ({
  177. inputs: `vtemp${r}`,
  178. filter: 'scale',
  179. options: `w=-2:h=${r}`,
  180. outputs: `vout${r}`
  181. }))
  182. ])
  183. command.outputOption('-preset superfast')
  184. command.outputOption('-sc_threshold 0')
  185. addDefaultEncoderGlobalParams({ command })
  186. for (let i = 0; i < resolutions.length; i++) {
  187. const resolution = resolutions[i]
  188. const resolutionFPS = computeFPS(fps, resolution)
  189. const baseEncoderBuilderParams = {
  190. input,
  191. availableEncoders,
  192. profile,
  193. fps: resolutionFPS,
  194. resolution,
  195. streamNum: i,
  196. videoType: 'live' as 'live'
  197. }
  198. {
  199. const builderResult = await getEncoderBuilderResult(Object.assign({}, baseEncoderBuilderParams, { streamType: 'VIDEO' }))
  200. if (!builderResult) {
  201. throw new Error('No available live video encoder found')
  202. }
  203. command.outputOption(`-map [vout${resolution}]`)
  204. addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
  205. logger.debug('Apply ffmpeg live video params from %s.', builderResult.encoder, builderResult)
  206. command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
  207. command.addOutputOptions(builderResult.result.outputOptions)
  208. }
  209. {
  210. const builderResult = await getEncoderBuilderResult(Object.assign({}, baseEncoderBuilderParams, { streamType: 'AUDIO' }))
  211. if (!builderResult) {
  212. throw new Error('No available live audio encoder found')
  213. }
  214. command.outputOption('-map a:0')
  215. addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
  216. logger.debug('Apply ffmpeg live audio params from %s.', builderResult.encoder, builderResult)
  217. command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
  218. command.addOutputOptions(builderResult.result.outputOptions)
  219. }
  220. varStreamMap.push(`v:${i},a:${i}`)
  221. }
  222. addDefaultLiveHLSParams(command, outPath)
  223. command.outputOption('-var_stream_map', varStreamMap.join(' '))
  224. return command
  225. }
  226. function getLiveMuxingCommand (rtmpUrl: string, outPath: string) {
  227. const command = getFFmpeg(rtmpUrl, 'live')
  228. command.outputOption('-c:v copy')
  229. command.outputOption('-c:a copy')
  230. command.outputOption('-map 0:a?')
  231. command.outputOption('-map 0:v?')
  232. addDefaultLiveHLSParams(command, outPath)
  233. return command
  234. }
  235. function buildStreamSuffix (base: string, streamNum?: number) {
  236. if (streamNum !== undefined) {
  237. return `${base}:${streamNum}`
  238. }
  239. return base
  240. }
  241. // ---------------------------------------------------------------------------
  242. // Default options
  243. // ---------------------------------------------------------------------------
  244. function addDefaultEncoderGlobalParams (options: {
  245. command: ffmpeg.FfmpegCommand
  246. }) {
  247. const { command } = options
  248. // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
  249. command.outputOption('-max_muxing_queue_size 1024')
  250. // strip all metadata
  251. .outputOption('-map_metadata -1')
  252. // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
  253. .outputOption('-b_strategy 1')
  254. // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
  255. .outputOption('-bf 16')
  256. // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
  257. .outputOption('-pix_fmt yuv420p')
  258. }
  259. function addDefaultEncoderParams (options: {
  260. command: ffmpeg.FfmpegCommand
  261. encoder: 'libx264' | string
  262. streamNum?: number
  263. fps?: number
  264. }) {
  265. const { command, encoder, fps, streamNum } = options
  266. if (encoder === 'libx264') {
  267. // 3.1 is the minimal resource allocation for our highest supported resolution
  268. command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
  269. if (fps) {
  270. // Keyframe interval of 2 seconds for faster seeking and resolution switching.
  271. // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
  272. // https://superuser.com/a/908325
  273. command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
  274. }
  275. }
  276. }
  277. function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) {
  278. command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
  279. command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
  280. command.outputOption('-hls_flags delete_segments+independent_segments')
  281. command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
  282. command.outputOption('-master_pl_name master.m3u8')
  283. command.outputOption(`-f hls`)
  284. command.output(join(outPath, '%v.m3u8'))
  285. }
  286. // ---------------------------------------------------------------------------
  287. // Transcode VOD command builders
  288. // ---------------------------------------------------------------------------
  289. async function buildx264VODCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
  290. let fps = await getVideoFileFPS(options.inputPath)
  291. fps = computeFPS(fps, options.resolution)
  292. command = await presetVideo(command, options.inputPath, options, fps)
  293. if (options.resolution !== undefined) {
  294. // '?x720' or '720x?' for example
  295. const size = options.isPortraitMode === true
  296. ? `${options.resolution}x?`
  297. : `?x${options.resolution}`
  298. command = command.size(size)
  299. }
  300. return command
  301. }
  302. async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
  303. command = command.loop(undefined)
  304. command = await presetVideo(command, options.audioPath, options)
  305. command.outputOption('-preset:v veryfast')
  306. command = command.input(options.audioPath)
  307. .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
  308. .outputOption('-tune stillimage')
  309. .outputOption('-shortest')
  310. return command
  311. }
  312. function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
  313. command = presetOnlyAudio(command)
  314. return command
  315. }
  316. function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
  317. command = presetCopy(command)
  318. command = command.outputOption('-map_metadata -1') // strip all metadata
  319. .outputOption('-movflags faststart')
  320. return command
  321. }
  322. function addCommonHLSVODCommandOptions (command: ffmpeg.FfmpegCommand, outputPath: string) {
  323. return command.outputOption('-hls_time 4')
  324. .outputOption('-hls_list_size 0')
  325. .outputOption('-hls_playlist_type vod')
  326. .outputOption('-hls_segment_filename ' + outputPath)
  327. .outputOption('-hls_segment_type fmp4')
  328. .outputOption('-f hls')
  329. .outputOption('-hls_flags single_file')
  330. }
  331. async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
  332. const videoPath = getHLSVideoPath(options)
  333. if (options.copyCodecs) command = presetCopy(command)
  334. else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
  335. else command = await buildx264VODCommand(command, options)
  336. addCommonHLSVODCommandOptions(command, videoPath)
  337. return command
  338. }
  339. async function buildHLSVODFromTSCommand (command: ffmpeg.FfmpegCommand, options: HLSFromTSTranscodeOptions) {
  340. const videoPath = getHLSVideoPath(options)
  341. command.outputOption('-c copy')
  342. if (options.isAAC) {
  343. // Required for example when copying an AAC stream from an MPEG-TS
  344. // Since it's a bitstream filter, we don't need to reencode the audio
  345. command.outputOption('-bsf:a aac_adtstoasc')
  346. }
  347. addCommonHLSVODCommandOptions(command, videoPath)
  348. return command
  349. }
  350. async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
  351. if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
  352. const fileContent = await readFile(options.outputPath)
  353. const videoFileName = options.hlsPlaylist.videoFilename
  354. const videoFilePath = getHLSVideoPath(options)
  355. // Fix wrong mapping with some ffmpeg versions
  356. const newContent = fileContent.toString()
  357. .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
  358. await writeFile(options.outputPath, newContent)
  359. }
  360. function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
  361. return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
  362. }
  363. // ---------------------------------------------------------------------------
  364. // Transcoding presets
  365. // ---------------------------------------------------------------------------
  366. async function getEncoderBuilderResult (options: {
  367. streamType: string
  368. input: string
  369. availableEncoders: AvailableEncoders
  370. profile: string
  371. videoType: 'vod' | 'live'
  372. resolution: number
  373. fps?: number
  374. streamNum?: number
  375. }) {
  376. const { availableEncoders, input, profile, resolution, streamType, fps, streamNum, videoType } = options
  377. const encodersToTry: string[] = VIDEO_TRANSCODING_ENCODERS[streamType]
  378. for (const encoder of encodersToTry) {
  379. if (!(await checkFFmpegEncoders()).get(encoder) || !availableEncoders[videoType][encoder]) continue
  380. const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = availableEncoders[videoType][encoder]
  381. let builder = builderProfiles[profile]
  382. if (!builder) {
  383. logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder)
  384. builder = builderProfiles.default
  385. }
  386. const result = await builder({ input, resolution: resolution, fps, streamNum })
  387. return {
  388. result,
  389. // If we don't have output options, then copy the input stream
  390. encoder: result.copy === true
  391. ? 'copy'
  392. : encoder
  393. }
  394. }
  395. return null
  396. }
  397. async function presetVideo (
  398. command: ffmpeg.FfmpegCommand,
  399. input: string,
  400. transcodeOptions: TranscodeOptions,
  401. fps?: number
  402. ) {
  403. let localCommand = command
  404. .format('mp4')
  405. .outputOption('-movflags faststart')
  406. addDefaultEncoderGlobalParams({ command })
  407. // Audio encoder
  408. const parsedAudio = await getAudioStream(input)
  409. let streamsToProcess = [ 'AUDIO', 'VIDEO' ]
  410. if (!parsedAudio.audioStream) {
  411. localCommand = localCommand.noAudio()
  412. streamsToProcess = [ 'VIDEO' ]
  413. }
  414. for (const streamType of streamsToProcess) {
  415. const { profile, resolution, availableEncoders } = transcodeOptions
  416. const builderResult = await getEncoderBuilderResult({
  417. streamType,
  418. input,
  419. resolution,
  420. availableEncoders,
  421. profile,
  422. fps,
  423. videoType: 'vod' as 'vod'
  424. })
  425. if (!builderResult) {
  426. throw new Error('No available encoder found for stream ' + streamType)
  427. }
  428. logger.debug('Apply ffmpeg params from %s.', builderResult.encoder, builderResult)
  429. if (streamType === 'VIDEO') {
  430. localCommand.videoCodec(builderResult.encoder)
  431. } else if (streamType === 'AUDIO') {
  432. localCommand.audioCodec(builderResult.encoder)
  433. }
  434. command.addOutputOptions(builderResult.result.outputOptions)
  435. addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
  436. }
  437. return localCommand
  438. }
  439. function presetCopy (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
  440. return command
  441. .format('mp4')
  442. .videoCodec('copy')
  443. .audioCodec('copy')
  444. }
  445. function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
  446. return command
  447. .format('mp4')
  448. .audioCodec('copy')
  449. .noVideo()
  450. }
  451. // ---------------------------------------------------------------------------
  452. // Utils
  453. // ---------------------------------------------------------------------------
  454. function getFFmpeg (input: string, type: 'live' | 'vod') {
  455. // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
  456. const command = ffmpeg(input, {
  457. niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
  458. cwd: CONFIG.STORAGE.TMP_DIR
  459. })
  460. const threads = type === 'live'
  461. ? CONFIG.LIVE.TRANSCODING.THREADS
  462. : CONFIG.TRANSCODING.THREADS
  463. if (threads > 0) {
  464. // If we don't set any threads ffmpeg will chose automatically
  465. command.outputOption('-threads ' + threads)
  466. }
  467. return command
  468. }
  469. async function runCommand (command: ffmpeg.FfmpegCommand, job?: Job) {
  470. return new Promise<void>((res, rej) => {
  471. command.on('error', (err, stdout, stderr) => {
  472. logger.error('Error in transcoding job.', { stdout, stderr })
  473. rej(err)
  474. })
  475. command.on('end', (stdout, stderr) => {
  476. logger.debug('FFmpeg command ended.', { stdout, stderr })
  477. res()
  478. })
  479. if (job) {
  480. command.on('progress', progress => {
  481. if (!progress.percent) return
  482. job.progress(Math.round(progress.percent))
  483. .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err }))
  484. })
  485. }
  486. command.run()
  487. })
  488. }
  489. // ---------------------------------------------------------------------------
  490. export {
  491. getLiveTranscodingCommand,
  492. getLiveMuxingCommand,
  493. buildStreamSuffix,
  494. convertWebPToJPG,
  495. processGIF,
  496. generateImageFromVideoFile,
  497. TranscodeOptions,
  498. TranscodeOptionsType,
  499. transcode,
  500. runCommand,
  501. // builders
  502. buildx264VODCommand
  503. }