1 import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg'
2 import { pick, promisify0 } from '@shared/core-utils'
3 import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
6 info: (msg: string, obj?: any) => void
7 debug: (msg: string, obj?: any) => void
8 warn: (msg: string, obj?: any) => void
9 error: (msg: string, obj?: any) => void
12 export interface FFmpegCommandWrapperOptions {
13 availableEncoders?: AvailableEncoders
21 lTags?: { tags: string[] }
23 updateJobProgress?: (progress?: number) => void
26 export class FFmpegCommandWrapper {
27 private static supportedEncoders: Map<string, boolean>
29 private readonly availableEncoders: AvailableEncoders
30 private readonly profile: string
32 private readonly niceness: number
33 private readonly tmpDirectory: string
34 private readonly threads: number
36 private readonly logger: FFmpegLogger
37 private readonly lTags: { tags: string[] }
39 private readonly updateJobProgress: (progress?: number) => void
41 private command: FfmpegCommand
43 constructor (options: FFmpegCommandWrapperOptions) {
44 this.availableEncoders = options.availableEncoders
45 this.profile = options.profile
46 this.niceness = options.niceness
47 this.tmpDirectory = options.tmpDirectory
48 this.threads = options.threads
49 this.logger = options.logger
50 this.lTags = options.lTags || { tags: [] }
51 this.updateJobProgress = options.updateJobProgress
54 getAvailableEncoders () {
55 return this.availableEncoders
66 // ---------------------------------------------------------------------------
68 debugLog (msg: string, meta: any) {
69 this.logger.debug(msg, { ...meta, ...this.lTags })
72 // ---------------------------------------------------------------------------
74 buildCommand (input: string) {
75 if (this.command) throw new Error('Command is already built')
77 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
78 this.command = ffmpeg(input, {
79 niceness: this.niceness,
80 cwd: this.tmpDirectory
83 if (this.threads > 0) {
84 // If we don't set any threads ffmpeg will chose automatically
85 this.command.outputOption('-threads ' + this.threads)
91 async runCommand (options: {
92 silent?: boolean // false by default
94 const { silent = false } = options
96 return new Promise<void>((res, rej) => {
97 let shellCommand: string
99 this.command.on('start', cmdline => { shellCommand = cmdline })
101 this.command.on('error', (err, stdout, stderr) => {
102 if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
107 this.command.on('end', (stdout, stderr) => {
108 this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
113 if (this.updateJobProgress) {
114 this.command.on('progress', progress => {
115 if (!progress.percent) return
117 // Sometimes ffmpeg returns an invalid progress
118 let percent = Math.round(progress.percent)
119 if (percent < 0) percent = 0
120 if (percent > 100) percent = 100
122 this.updateJobProgress(percent)
130 // ---------------------------------------------------------------------------
132 static resetSupportedEncoders () {
133 FFmpegCommandWrapper.supportedEncoders = undefined
136 // Run encoder builder depending on available encoders
137 // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
138 // If the default one does not exist, check the next encoder
139 async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
140 streamType: 'video' | 'audio'
143 videoType: 'vod' | 'live'
145 if (!this.availableEncoders) {
146 throw new Error('There is no available encoders')
149 const { streamType, videoType } = options
151 const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
152 const encoders = this.availableEncoders.available[videoType]
154 for (const encoder of encodersToTry) {
155 if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
156 this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
160 if (!encoders[encoder]) {
161 this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
165 // An object containing available profiles for this encoder
166 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
167 let builder = builderProfiles[this.profile]
170 this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
171 builder = builderProfiles.default
174 this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
179 const result = await builder(
195 // If we don't have output options, then copy the input stream
196 encoder: result.copy === true
205 // Detect supported encoders by ffmpeg
206 private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
207 if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
208 return FFmpegCommandWrapper.supportedEncoders
211 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
212 const availableFFmpegEncoders = await getAvailableEncodersPromise()
214 const searchEncoders = new Set<string>()
215 for (const type of [ 'live', 'vod' ]) {
216 for (const streamType of [ 'audio', 'video' ]) {
217 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
218 searchEncoders.add(encoder)
223 const supportedEncoders = new Map<string, boolean>()
225 for (const searchEncoder of searchEncoders) {
226 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
229 this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
231 FFmpegCommandWrapper.supportedEncoders = supportedEncoders
232 return supportedEncoders