aboutsummaryrefslogblamecommitdiffhomepage
path: root/shared/ffmpeg/ffmpeg-vod.ts
blob: e40ca0a1e719790231e5a51326a44c7879a122d1 (plain) (tree)































































































































































































































































                                                                                                                        
import { MutexInterface } from 'async-mutex'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { readFile, writeFile } from 'fs-extra'
import { dirname } from 'path'
import { pick } from '@shared/core-utils'
import { VideoResolution } from '@shared/models'
import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe'
import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets'

export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'

export interface BaseTranscodeVODOptions {
  type: TranscodeVODOptionsType

  inputPath: string
  outputPath: string

  // Will be released after the ffmpeg started
  // To prevent a bug where the input file does not exist anymore when running ffmpeg
  inputFileMutexReleaser: MutexInterface.Releaser

  resolution: number
  fps: number
}

export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
  type: 'hls'

  copyCodecs: boolean

  hlsPlaylist: {
    videoFilename: string
  }
}

export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
  type: 'hls-from-ts'

  isAAC: boolean

  hlsPlaylist: {
    videoFilename: string
  }
}

export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
  type: 'quick-transcode'
}

export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
  type: 'video'
}

export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
  type: 'merge-audio'
  audioPath: string
}

export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
  type: 'only-audio'
}

export type TranscodeVODOptions =
  HLSTranscodeOptions
  | HLSFromTSTranscodeOptions
  | VideoTranscodeOptions
  | MergeAudioTranscodeOptions
  | OnlyAudioTranscodeOptions
  | QuickTranscodeOptions

// ---------------------------------------------------------------------------

export class FFmpegVOD {
  private readonly commandWrapper: FFmpegCommandWrapper

  private ended = false

  constructor (options: FFmpegCommandWrapperOptions) {
    this.commandWrapper = new FFmpegCommandWrapper(options)
  }

  async transcode (options: TranscodeVODOptions) {
    const builders: {
      [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
    } = {
      'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
      'hls': this.buildHLSVODCommand.bind(this),
      'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
      'merge-audio': this.buildAudioMergeCommand.bind(this),
      // TODO: remove, we merge this in buildWebVideoCommand
      'only-audio': this.buildOnlyAudioCommand.bind(this),
      'video': this.buildWebVideoCommand.bind(this)
    }

    this.commandWrapper.debugLog('Will run transcode.', { options })

    const command = this.commandWrapper.buildCommand(options.inputPath)
      .output(options.outputPath)

    await builders[options.type](options)

    command.on('start', () => {
      setTimeout(() => {
        options.inputFileMutexReleaser()
      }, 1000)
    })

    await this.commandWrapper.runCommand()

    await this.fixHLSPlaylistIfNeeded(options)

    this.ended = true
  }

  isEnded () {
    return this.ended
  }

  private async buildWebVideoCommand (options: TranscodeVODOptions) {
    const { resolution, fps, inputPath } = options

    if (resolution === VideoResolution.H_NOVIDEO) {
      presetOnlyAudio(this.commandWrapper)
      return
    }

    let scaleFilterValue: string

    if (resolution !== undefined) {
      const probe = await ffprobePromise(inputPath)
      const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)

      scaleFilterValue = videoStreamInfo?.isPortraitMode === true
        ? `w=${resolution}:h=-2`
        : `w=-2:h=${resolution}`
    }

    await presetVOD({
      commandWrapper: this.commandWrapper,

      resolution,
      input: inputPath,
      canCopyAudio: true,
      canCopyVideo: true,
      fps,
      scaleFilterValue
    })
  }

  private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
    const command = this.commandWrapper.getCommand()

    presetCopy(this.commandWrapper)

    command.outputOption('-map_metadata -1') // strip all metadata
      .outputOption('-movflags faststart')
  }

  // ---------------------------------------------------------------------------
  // Audio transcoding
  // ---------------------------------------------------------------------------

  private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
    const command = this.commandWrapper.getCommand()

    command.loop(undefined)

    await presetVOD({
      ...pick(options, [ 'resolution' ]),

      commandWrapper: this.commandWrapper,
      input: options.audioPath,
      canCopyAudio: true,
      canCopyVideo: true,
      fps: options.fps,
      scaleFilterValue: this.getMergeAudioScaleFilterValue()
    })

    command.outputOption('-preset:v veryfast')

    command.input(options.audioPath)
      .outputOption('-tune stillimage')
      .outputOption('-shortest')
  }

  private buildOnlyAudioCommand (_options: OnlyAudioTranscodeOptions) {
    presetOnlyAudio(this.commandWrapper)
  }

  // Avoid "height not divisible by 2" error
  private getMergeAudioScaleFilterValue () {
    return 'trunc(iw/2)*2:trunc(ih/2)*2'
  }

  // ---------------------------------------------------------------------------
  // HLS transcoding
  // ---------------------------------------------------------------------------

  private async buildHLSVODCommand (options: HLSTranscodeOptions) {
    const command = this.commandWrapper.getCommand()

    const videoPath = this.getHLSVideoPath(options)

    if (options.copyCodecs) presetCopy(this.commandWrapper)
    else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper)
    else await this.buildWebVideoCommand(options)

    this.addCommonHLSVODCommandOptions(command, videoPath)
  }

  private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
    const command = this.commandWrapper.getCommand()

    const videoPath = this.getHLSVideoPath(options)

    command.outputOption('-c copy')

    if (options.isAAC) {
      // Required for example when copying an AAC stream from an MPEG-TS
      // Since it's a bitstream filter, we don't need to reencode the audio
      command.outputOption('-bsf:a aac_adtstoasc')
    }

    this.addCommonHLSVODCommandOptions(command, videoPath)
  }

  private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
    return command.outputOption('-hls_time 4')
                  .outputOption('-hls_list_size 0')
                  .outputOption('-hls_playlist_type vod')
                  .outputOption('-hls_segment_filename ' + outputPath)
                  .outputOption('-hls_segment_type fmp4')
                  .outputOption('-f hls')
                  .outputOption('-hls_flags single_file')
  }

  private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
    if (options.type !== 'hls' && options.type !== 'hls-from-ts') return

    const fileContent = await readFile(options.outputPath)

    const videoFileName = options.hlsPlaylist.videoFilename
    const videoFilePath = this.getHLSVideoPath(options)

    // Fix wrong mapping with some ffmpeg versions
    const newContent = fileContent.toString()
                                  .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)

    await writeFile(options.outputPath, newContent)
  }

  private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
    return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
  }
}