]>
Commit | Line | Data |
---|---|---|
0c9668f7 C |
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 | |
a34c612f C |
24 | onEnd?: () => void |
25 | onError?: (err: Error) => void | |
0c9668f7 C |
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 | |
a34c612f C |
42 | private readonly onEnd?: () => void |
43 | private readonly onError?: (err: Error) => void | |
0c9668f7 C |
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: [] } | |
a34c612f | 55 | |
0c9668f7 | 56 | this.updateJobProgress = options.updateJobProgress |
a34c612f C |
57 | |
58 | this.onEnd = options.onEnd | |
59 | this.onError = options.onError | |
0c9668f7 C |
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 | ||
a34c612f C |
112 | if (this.onError) this.onError(err) |
113 | ||
0c9668f7 C |
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 | ||
a34c612f C |
120 | if (this.onEnd) this.onEnd() |
121 | ||
0c9668f7 C |
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 | } |