diff options
Diffstat (limited to 'packages/ffmpeg/src/ffmpeg-command-wrapper.ts')
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-command-wrapper.ts | 246 |
1 files changed, 246 insertions, 0 deletions
diff --git a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts new file mode 100644 index 000000000..647ee3996 --- /dev/null +++ b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts | |||
@@ -0,0 +1,246 @@ | |||
1 | import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { pick, promisify0 } from '@peertube/peertube-core-utils' | ||
3 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@peertube/peertube-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(ffmpeg.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 | } | ||