ffmpeg-vod.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { pick } from '@peertube/peertube-core-utils'
  2. import { VideoResolution } from '@peertube/peertube-models'
  3. import { MutexInterface } from 'async-mutex'
  4. import { FfmpegCommand } from 'fluent-ffmpeg'
  5. import { readFile, writeFile } from 'fs/promises'
  6. import { dirname } from 'path'
  7. import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
  8. import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
  9. import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js'
  10. export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio'
  11. export interface BaseTranscodeVODOptions {
  12. type: TranscodeVODOptionsType
  13. inputPath: string
  14. outputPath: string
  15. // Will be released after the ffmpeg started
  16. // To prevent a bug where the input file does not exist anymore when running ffmpeg
  17. inputFileMutexReleaser: MutexInterface.Releaser
  18. resolution: number
  19. fps: number
  20. }
  21. export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
  22. type: 'hls'
  23. copyCodecs: boolean
  24. hlsPlaylist: {
  25. videoFilename: string
  26. }
  27. }
  28. export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
  29. type: 'hls-from-ts'
  30. isAAC: boolean
  31. hlsPlaylist: {
  32. videoFilename: string
  33. }
  34. }
  35. export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
  36. type: 'quick-transcode'
  37. }
  38. export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
  39. type: 'video'
  40. }
  41. export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
  42. type: 'merge-audio'
  43. audioPath: string
  44. }
  45. export type TranscodeVODOptions =
  46. HLSTranscodeOptions
  47. | HLSFromTSTranscodeOptions
  48. | VideoTranscodeOptions
  49. | MergeAudioTranscodeOptions
  50. | QuickTranscodeOptions
  51. // ---------------------------------------------------------------------------
  52. export class FFmpegVOD {
  53. private readonly commandWrapper: FFmpegCommandWrapper
  54. private ended = false
  55. constructor (options: FFmpegCommandWrapperOptions) {
  56. this.commandWrapper = new FFmpegCommandWrapper(options)
  57. }
  58. async transcode (options: TranscodeVODOptions) {
  59. const builders: {
  60. [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
  61. } = {
  62. 'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
  63. 'hls': this.buildHLSVODCommand.bind(this),
  64. 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
  65. 'merge-audio': this.buildAudioMergeCommand.bind(this),
  66. 'video': this.buildWebVideoCommand.bind(this)
  67. }
  68. this.commandWrapper.debugLog('Will run transcode.', { options })
  69. const command = this.commandWrapper.buildCommand(options.inputPath)
  70. .output(options.outputPath)
  71. await builders[options.type](options)
  72. command.on('start', () => {
  73. setTimeout(() => {
  74. if (options.inputFileMutexReleaser) {
  75. options.inputFileMutexReleaser()
  76. }
  77. }, 1000)
  78. })
  79. await this.commandWrapper.runCommand()
  80. await this.fixHLSPlaylistIfNeeded(options)
  81. this.ended = true
  82. }
  83. isEnded () {
  84. return this.ended
  85. }
  86. private async buildWebVideoCommand (options: TranscodeVODOptions) {
  87. const { resolution, fps, inputPath } = options
  88. if (resolution === VideoResolution.H_NOVIDEO) {
  89. presetOnlyAudio(this.commandWrapper)
  90. return
  91. }
  92. let scaleFilterValue: string
  93. if (resolution !== undefined) {
  94. const probe = await ffprobePromise(inputPath)
  95. const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
  96. scaleFilterValue = videoStreamInfo?.isPortraitMode === true
  97. ? `w=${resolution}:h=-2`
  98. : `w=-2:h=${resolution}`
  99. }
  100. await presetVOD({
  101. commandWrapper: this.commandWrapper,
  102. resolution,
  103. input: inputPath,
  104. canCopyAudio: true,
  105. canCopyVideo: true,
  106. fps,
  107. scaleFilterValue
  108. })
  109. }
  110. private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
  111. const command = this.commandWrapper.getCommand()
  112. presetCopy(this.commandWrapper)
  113. command.outputOption('-map_metadata -1') // strip all metadata
  114. .outputOption('-movflags faststart')
  115. }
  116. // ---------------------------------------------------------------------------
  117. // Audio transcoding
  118. // ---------------------------------------------------------------------------
  119. private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
  120. const command = this.commandWrapper.getCommand()
  121. command.loop(undefined)
  122. await presetVOD({
  123. ...pick(options, [ 'resolution' ]),
  124. commandWrapper: this.commandWrapper,
  125. input: options.audioPath,
  126. canCopyAudio: true,
  127. canCopyVideo: true,
  128. fps: options.fps,
  129. scaleFilterValue: this.getMergeAudioScaleFilterValue()
  130. })
  131. command.outputOption('-preset:v veryfast')
  132. command.input(options.audioPath)
  133. .outputOption('-tune stillimage')
  134. .outputOption('-shortest')
  135. }
  136. // Avoid "height not divisible by 2" error
  137. private getMergeAudioScaleFilterValue () {
  138. return 'trunc(iw/2)*2:trunc(ih/2)*2'
  139. }
  140. // ---------------------------------------------------------------------------
  141. // HLS transcoding
  142. // ---------------------------------------------------------------------------
  143. private async buildHLSVODCommand (options: HLSTranscodeOptions) {
  144. const command = this.commandWrapper.getCommand()
  145. const videoPath = this.getHLSVideoPath(options)
  146. if (options.copyCodecs) presetCopy(this.commandWrapper)
  147. else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper)
  148. else await this.buildWebVideoCommand(options)
  149. this.addCommonHLSVODCommandOptions(command, videoPath)
  150. }
  151. private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
  152. const command = this.commandWrapper.getCommand()
  153. const videoPath = this.getHLSVideoPath(options)
  154. command.outputOption('-c copy')
  155. if (options.isAAC) {
  156. // Required for example when copying an AAC stream from an MPEG-TS
  157. // Since it's a bitstream filter, we don't need to reencode the audio
  158. command.outputOption('-bsf:a aac_adtstoasc')
  159. }
  160. this.addCommonHLSVODCommandOptions(command, videoPath)
  161. }
  162. private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
  163. return command.outputOption('-hls_time 4')
  164. .outputOption('-hls_list_size 0')
  165. .outputOption('-hls_playlist_type vod')
  166. .outputOption('-hls_segment_filename ' + outputPath)
  167. .outputOption('-hls_segment_type fmp4')
  168. .outputOption('-f hls')
  169. .outputOption('-hls_flags single_file')
  170. }
  171. private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
  172. if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
  173. const fileContent = await readFile(options.outputPath)
  174. const videoFileName = options.hlsPlaylist.videoFilename
  175. const videoFilePath = this.getHLSVideoPath(options)
  176. // Fix wrong mapping with some ffmpeg versions
  177. const newContent = fileContent.toString()
  178. .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
  179. await writeFile(options.outputPath, newContent)
  180. }
  181. private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
  182. return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
  183. }
  184. }