import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg' import { pick, promisify0 } from '@shared/core-utils' import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models' type FFmpegLogger = { info: (msg: string, obj?: any) => void debug: (msg: string, obj?: any) => void warn: (msg: string, obj?: any) => void error: (msg: string, obj?: any) => void } export interface FFmpegCommandWrapperOptions { availableEncoders?: AvailableEncoders profile?: string niceness: number tmpDirectory: string threads: number logger: FFmpegLogger lTags?: { tags: string[] } updateJobProgress?: (progress?: number) => void } export class FFmpegCommandWrapper { private static supportedEncoders: Map private readonly availableEncoders: AvailableEncoders private readonly profile: string private readonly niceness: number private readonly tmpDirectory: string private readonly threads: number private readonly logger: FFmpegLogger private readonly lTags: { tags: string[] } private readonly updateJobProgress: (progress?: number) => void private command: FfmpegCommand constructor (options: FFmpegCommandWrapperOptions) { this.availableEncoders = options.availableEncoders this.profile = options.profile this.niceness = options.niceness this.tmpDirectory = options.tmpDirectory this.threads = options.threads this.logger = options.logger this.lTags = options.lTags || { tags: [] } this.updateJobProgress = options.updateJobProgress } getAvailableEncoders () { return this.availableEncoders } getProfile () { return this.profile } getCommand () { return this.command } // --------------------------------------------------------------------------- debugLog (msg: string, meta: any) { this.logger.debug(msg, { ...meta, ...this.lTags }) } // --------------------------------------------------------------------------- buildCommand (input: string) { if (this.command) throw new Error('Command is already built') // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems this.command = ffmpeg(input, { niceness: this.niceness, cwd: this.tmpDirectory }) if (this.threads > 0) { // If we don't set any threads ffmpeg will chose automatically this.command.outputOption('-threads ' + this.threads) } return this.command } async runCommand (options: { silent?: boolean // false by default } = {}) { const { silent = false } = options return new Promise((res, rej) => { let shellCommand: string this.command.on('start', cmdline => { shellCommand = cmdline }) this.command.on('error', (err, stdout, stderr) => { if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags }) rej(err) }) this.command.on('end', (stdout, stderr) => { this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags }) res() }) if (this.updateJobProgress) { this.command.on('progress', progress => { if (!progress.percent) return // Sometimes ffmpeg returns an invalid progress let percent = Math.round(progress.percent) if (percent < 0) percent = 0 if (percent > 100) percent = 100 this.updateJobProgress(percent) }) } this.command.run() }) } // --------------------------------------------------------------------------- static resetSupportedEncoders () { FFmpegCommandWrapper.supportedEncoders = undefined } // Run encoder builder depending on available encoders // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one // If the default one does not exist, check the next encoder async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & { streamType: 'video' | 'audio' input: string videoType: 'vod' | 'live' }) { if (!this.availableEncoders) { throw new Error('There is no available encoders') } const { streamType, videoType } = options const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType] const encoders = this.availableEncoders.available[videoType] for (const encoder of encodersToTry) { if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) { this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags) continue } if (!encoders[encoder]) { this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags) continue } // An object containing available profiles for this encoder const builderProfiles: EncoderProfile = encoders[encoder] let builder = builderProfiles[this.profile] if (!builder) { this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags) builder = builderProfiles.default if (!builder) { this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags) continue } } const result = await builder( pick(options, [ 'input', 'canCopyAudio', 'canCopyVideo', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]) ) return { result, // If we don't have output options, then copy the input stream encoder: result.copy === true ? 'copy' : encoder } } return null } // Detect supported encoders by ffmpeg private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise> { if (FFmpegCommandWrapper.supportedEncoders !== undefined) { return FFmpegCommandWrapper.supportedEncoders } const getAvailableEncodersPromise = promisify0(getAvailableEncoders) const availableFFmpegEncoders = await getAvailableEncodersPromise() const searchEncoders = new Set() for (const type of [ 'live', 'vod' ]) { for (const streamType of [ 'audio', 'video' ]) { for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) { searchEncoders.add(encoder) } } } const supportedEncoders = new Map() for (const searchEncoder of searchEncoders) { supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined) } this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags }) FFmpegCommandWrapper.supportedEncoders = supportedEncoders return supportedEncoders } }