import { pick } from '@peertube/peertube-core-utils' import { VideoResolution } from '@peertube/peertube-models' import { MutexInterface } from 'async-mutex' import { FfmpegCommand } from 'fluent-ffmpeg' import { readFile, writeFile } from 'fs/promises' import { dirname } from 'path' import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js' import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js' export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-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 type TranscodeVODOptions = HLSTranscodeOptions | HLSFromTSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | 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 } = { 'quick-transcode': this.buildQuickTranscodeCommand.bind(this), 'hls': this.buildHLSVODCommand.bind(this), 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this), 'merge-audio': this.buildAudioMergeCommand.bind(this), 'video': this.buildWebVideoCommand.bind(this) } this.commandWrapper.debugLog('Will run transcode.', { options }) this.commandWrapper.buildCommand(options.inputPath, options.inputFileMutexReleaser) .output(options.outputPath) await builders[options.type](options) await this.commandWrapper.runCommand() await this.fixHLSPlaylistIfNeeded(options) this.ended = true } isEnded () { return this.ended } private async buildWebVideoCommand (options: TranscodeVODOptions & { canCopyAudio?: boolean, canCopyVideo?: boolean }) { const { resolution, fps, inputPath, canCopyAudio = true, canCopyVideo = true } = 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, canCopyVideo, 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') } // 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 { // If we cannot copy codecs, we do not copy them at all to prevent issues like audio desync // See for example https://github.com/Chocobozzz/PeerTube/issues/6438 await this.buildWebVideoCommand({ ...options, canCopyAudio: false, canCopyVideo: false }) } 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}` } }