]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - shared/ffmpeg/ffmpeg-command-wrapper.ts
More robust runner update handler
[github/Chocobozzz/PeerTube.git] / shared / ffmpeg / ffmpeg-command-wrapper.ts
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'
4
5 type FFmpegLogger = {
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
10 }
11
12 export interface FFmpegCommandWrapperOptions {
13 availableEncoders?: AvailableEncoders
14 profile?: string
15
16 niceness: number
17 tmpDirectory: string
18 threads: number
19
20 logger: FFmpegLogger
21 lTags?: { tags: string[] }
22
23 updateJobProgress?: (progress?: number) => void
24 onEnd?: () => void
25 onError?: (err: Error) => void
26 }
27
28 export class FFmpegCommandWrapper {
29 private static supportedEncoders: Map<string, boolean>
30
31 private readonly availableEncoders: AvailableEncoders
32 private readonly profile: string
33
34 private readonly niceness: number
35 private readonly tmpDirectory: string
36 private readonly threads: number
37
38 private readonly logger: FFmpegLogger
39 private readonly lTags: { tags: string[] }
40
41 private readonly updateJobProgress: (progress?: number) => void
42 private readonly onEnd?: () => void
43 private readonly onError?: (err: Error) => void
44
45 private command: FfmpegCommand
46
47 constructor (options: FFmpegCommandWrapperOptions) {
48 this.availableEncoders = options.availableEncoders
49 this.profile = options.profile
50 this.niceness = options.niceness
51 this.tmpDirectory = options.tmpDirectory
52 this.threads = options.threads
53 this.logger = options.logger
54 this.lTags = options.lTags || { tags: [] }
55
56 this.updateJobProgress = options.updateJobProgress
57
58 this.onEnd = options.onEnd
59 this.onError = options.onError
60 }
61
62 getAvailableEncoders () {
63 return this.availableEncoders
64 }
65
66 getProfile () {
67 return this.profile
68 }
69
70 getCommand () {
71 return this.command
72 }
73
74 // ---------------------------------------------------------------------------
75
76 debugLog (msg: string, meta: any) {
77 this.logger.debug(msg, { ...meta, ...this.lTags })
78 }
79
80 // ---------------------------------------------------------------------------
81
82 buildCommand (input: string) {
83 if (this.command) throw new Error('Command is already built')
84
85 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
86 this.command = ffmpeg(input, {
87 niceness: this.niceness,
88 cwd: this.tmpDirectory
89 })
90
91 if (this.threads > 0) {
92 // If we don't set any threads ffmpeg will chose automatically
93 this.command.outputOption('-threads ' + this.threads)
94 }
95
96 return this.command
97 }
98
99 async runCommand (options: {
100 silent?: boolean // false by default
101 } = {}) {
102 const { silent = false } = options
103
104 return new Promise<void>((res, rej) => {
105 let shellCommand: string
106
107 this.command.on('start', cmdline => { shellCommand = cmdline })
108
109 this.command.on('error', (err, stdout, stderr) => {
110 if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
111
112 if (this.onError) this.onError(err)
113
114 rej(err)
115 })
116
117 this.command.on('end', (stdout, stderr) => {
118 this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
119
120 if (this.onEnd) this.onEnd()
121
122 res()
123 })
124
125 if (this.updateJobProgress) {
126 this.command.on('progress', progress => {
127 if (!progress.percent) return
128
129 // Sometimes ffmpeg returns an invalid progress
130 let percent = Math.round(progress.percent)
131 if (percent < 0) percent = 0
132 if (percent > 100) percent = 100
133
134 this.updateJobProgress(percent)
135 })
136 }
137
138 this.command.run()
139 })
140 }
141
142 // ---------------------------------------------------------------------------
143
144 static resetSupportedEncoders () {
145 FFmpegCommandWrapper.supportedEncoders = undefined
146 }
147
148 // Run encoder builder depending on available encoders
149 // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
150 // If the default one does not exist, check the next encoder
151 async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
152 streamType: 'video' | 'audio'
153 input: string
154
155 videoType: 'vod' | 'live'
156 }) {
157 if (!this.availableEncoders) {
158 throw new Error('There is no available encoders')
159 }
160
161 const { streamType, videoType } = options
162
163 const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
164 const encoders = this.availableEncoders.available[videoType]
165
166 for (const encoder of encodersToTry) {
167 if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
168 this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
169 continue
170 }
171
172 if (!encoders[encoder]) {
173 this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
174 continue
175 }
176
177 // An object containing available profiles for this encoder
178 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
179 let builder = builderProfiles[this.profile]
180
181 if (!builder) {
182 this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
183 builder = builderProfiles.default
184
185 if (!builder) {
186 this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
187 continue
188 }
189 }
190
191 const result = await builder(
192 pick(options, [
193 'input',
194 'canCopyAudio',
195 'canCopyVideo',
196 'resolution',
197 'inputBitrate',
198 'fps',
199 'inputRatio',
200 'streamNum'
201 ])
202 )
203
204 return {
205 result,
206
207 // If we don't have output options, then copy the input stream
208 encoder: result.copy === true
209 ? 'copy'
210 : encoder
211 }
212 }
213
214 return null
215 }
216
217 // Detect supported encoders by ffmpeg
218 private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
219 if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
220 return FFmpegCommandWrapper.supportedEncoders
221 }
222
223 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
224 const availableFFmpegEncoders = await getAvailableEncodersPromise()
225
226 const searchEncoders = new Set<string>()
227 for (const type of [ 'live', 'vod' ]) {
228 for (const streamType of [ 'audio', 'video' ]) {
229 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
230 searchEncoders.add(encoder)
231 }
232 }
233 }
234
235 const supportedEncoders = new Map<string, boolean>()
236
237 for (const searchEncoder of searchEncoders) {
238 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
239 }
240
241 this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
242
243 FFmpegCommandWrapper.supportedEncoders = supportedEncoders
244 return supportedEncoders
245 }
246 }