1 import { MutexInterface } from 'async-mutex'
2 import { FfmpegCommand } from 'fluent-ffmpeg'
3 import { readFile, writeFile } from 'fs-extra'
4 import { dirname } from 'path'
5 import { pick } from '@shared/core-utils'
6 import { VideoResolution } from '@shared/models'
7 import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
8 import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe'
9 import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets'
11 export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
13 export interface BaseTranscodeVODOptions {
14 type: TranscodeVODOptionsType
19 // Will be released after the ffmpeg started
20 // To prevent a bug where the input file does not exist anymore when running ffmpeg
21 inputFileMutexReleaser: MutexInterface.Releaser
27 export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
37 export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
47 export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
48 type: 'quick-transcode'
51 export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
55 export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
60 export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
64 export type TranscodeVODOptions =
66 | HLSFromTSTranscodeOptions
67 | VideoTranscodeOptions
68 | MergeAudioTranscodeOptions
69 | OnlyAudioTranscodeOptions
70 | QuickTranscodeOptions
72 // ---------------------------------------------------------------------------
74 export class FFmpegVOD {
75 private readonly commandWrapper: FFmpegCommandWrapper
79 constructor (options: FFmpegCommandWrapperOptions) {
80 this.commandWrapper = new FFmpegCommandWrapper(options)
83 async transcode (options: TranscodeVODOptions) {
85 [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
87 'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
88 'hls': this.buildHLSVODCommand.bind(this),
89 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
90 'merge-audio': this.buildAudioMergeCommand.bind(this),
91 // TODO: remove, we merge this in buildWebVideoCommand
92 'only-audio': this.buildOnlyAudioCommand.bind(this),
93 'video': this.buildWebVideoCommand.bind(this)
96 this.commandWrapper.debugLog('Will run transcode.', { options })
98 const command = this.commandWrapper.buildCommand(options.inputPath)
99 .output(options.outputPath)
101 await builders[options.type](options)
103 command.on('start', () => {
105 options.inputFileMutexReleaser()
109 await this.commandWrapper.runCommand()
111 await this.fixHLSPlaylistIfNeeded(options)
120 private async buildWebVideoCommand (options: TranscodeVODOptions) {
121 const { resolution, fps, inputPath } = options
123 if (resolution === VideoResolution.H_NOVIDEO) {
124 presetOnlyAudio(this.commandWrapper)
128 let scaleFilterValue: string
130 if (resolution !== undefined) {
131 const probe = await ffprobePromise(inputPath)
132 const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
134 scaleFilterValue = videoStreamInfo?.isPortraitMode === true
135 ? `w=${resolution}:h=-2`
136 : `w=-2:h=${resolution}`
140 commandWrapper: this.commandWrapper,
151 private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
152 const command = this.commandWrapper.getCommand()
154 presetCopy(this.commandWrapper)
156 command.outputOption('-map_metadata -1') // strip all metadata
157 .outputOption('-movflags faststart')
160 // ---------------------------------------------------------------------------
162 // ---------------------------------------------------------------------------
164 private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
165 const command = this.commandWrapper.getCommand()
167 command.loop(undefined)
170 ...pick(options, [ 'resolution' ]),
172 commandWrapper: this.commandWrapper,
173 input: options.audioPath,
177 scaleFilterValue: this.getMergeAudioScaleFilterValue()
180 command.outputOption('-preset:v veryfast')
182 command.input(options.audioPath)
183 .outputOption('-tune stillimage')
184 .outputOption('-shortest')
187 private buildOnlyAudioCommand (_options: OnlyAudioTranscodeOptions) {
188 presetOnlyAudio(this.commandWrapper)
191 // Avoid "height not divisible by 2" error
192 private getMergeAudioScaleFilterValue () {
193 return 'trunc(iw/2)*2:trunc(ih/2)*2'
196 // ---------------------------------------------------------------------------
198 // ---------------------------------------------------------------------------
200 private async buildHLSVODCommand (options: HLSTranscodeOptions) {
201 const command = this.commandWrapper.getCommand()
203 const videoPath = this.getHLSVideoPath(options)
205 if (options.copyCodecs) presetCopy(this.commandWrapper)
206 else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper)
207 else await this.buildWebVideoCommand(options)
209 this.addCommonHLSVODCommandOptions(command, videoPath)
212 private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
213 const command = this.commandWrapper.getCommand()
215 const videoPath = this.getHLSVideoPath(options)
217 command.outputOption('-c copy')
220 // Required for example when copying an AAC stream from an MPEG-TS
221 // Since it's a bitstream filter, we don't need to reencode the audio
222 command.outputOption('-bsf:a aac_adtstoasc')
225 this.addCommonHLSVODCommandOptions(command, videoPath)
228 private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
229 return command.outputOption('-hls_time 4')
230 .outputOption('-hls_list_size 0')
231 .outputOption('-hls_playlist_type vod')
232 .outputOption('-hls_segment_filename ' + outputPath)
233 .outputOption('-hls_segment_type fmp4')
234 .outputOption('-f hls')
235 .outputOption('-hls_flags single_file')
238 private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
239 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
241 const fileContent = await readFile(options.outputPath)
243 const videoFileName = options.hlsPlaylist.videoFilename
244 const videoFilePath = this.getHLSVideoPath(options)
246 // Fix wrong mapping with some ffmpeg versions
247 const newContent = fileContent.toString()
248 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
250 await writeFile(options.outputPath, newContent)
253 private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
254 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`