]>
Commit | Line | Data |
---|---|---|
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 | } | |
25 | ||
26 | export class FFmpegCommandWrapper { | |
27 | private static supportedEncoders: Map<string, boolean> | |
28 | ||
29 | private readonly availableEncoders: AvailableEncoders | |
30 | private readonly profile: string | |
31 | ||
32 | private readonly niceness: number | |
33 | private readonly tmpDirectory: string | |
34 | private readonly threads: number | |
35 | ||
36 | private readonly logger: FFmpegLogger | |
37 | private readonly lTags: { tags: string[] } | |
38 | ||
39 | private readonly updateJobProgress: (progress?: number) => void | |
40 | ||
41 | private command: FfmpegCommand | |
42 | ||
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 | |
52 | } | |
53 | ||
54 | getAvailableEncoders () { | |
55 | return this.availableEncoders | |
56 | } | |
57 | ||
58 | getProfile () { | |
59 | return this.profile | |
60 | } | |
61 | ||
62 | getCommand () { | |
63 | return this.command | |
64 | } | |
65 | ||
66 | // --------------------------------------------------------------------------- | |
67 | ||
68 | debugLog (msg: string, meta: any) { | |
69 | this.logger.debug(msg, { ...meta, ...this.lTags }) | |
70 | } | |
71 | ||
72 | // --------------------------------------------------------------------------- | |
73 | ||
74 | buildCommand (input: string) { | |
75 | if (this.command) throw new Error('Command is already built') | |
76 | ||
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 | |
81 | }) | |
82 | ||
83 | if (this.threads > 0) { | |
84 | // If we don't set any threads ffmpeg will chose automatically | |
85 | this.command.outputOption('-threads ' + this.threads) | |
86 | } | |
87 | ||
88 | return this.command | |
89 | } | |
90 | ||
91 | async runCommand (options: { | |
92 | silent?: boolean // false by default | |
93 | } = {}) { | |
94 | const { silent = false } = options | |
95 | ||
96 | return new Promise<void>((res, rej) => { | |
97 | let shellCommand: string | |
98 | ||
99 | this.command.on('start', cmdline => { shellCommand = cmdline }) | |
100 | ||
101 | this.command.on('error', (err, stdout, stderr) => { | |
102 | if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags }) | |
103 | ||
104 | rej(err) | |
105 | }) | |
106 | ||
107 | this.command.on('end', (stdout, stderr) => { | |
108 | this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags }) | |
109 | ||
110 | res() | |
111 | }) | |
112 | ||
113 | if (this.updateJobProgress) { | |
114 | this.command.on('progress', progress => { | |
115 | if (!progress.percent) return | |
116 | ||
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 | |
121 | ||
122 | this.updateJobProgress(percent) | |
123 | }) | |
124 | } | |
125 | ||
126 | this.command.run() | |
127 | }) | |
128 | } | |
129 | ||
130 | // --------------------------------------------------------------------------- | |
131 | ||
132 | static resetSupportedEncoders () { | |
133 | FFmpegCommandWrapper.supportedEncoders = undefined | |
134 | } | |
135 | ||
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' | |
141 | input: string | |
142 | ||
143 | videoType: 'vod' | 'live' | |
144 | }) { | |
145 | if (!this.availableEncoders) { | |
146 | throw new Error('There is no available encoders') | |
147 | } | |
148 | ||
149 | const { streamType, videoType } = options | |
150 | ||
151 | const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType] | |
152 | const encoders = this.availableEncoders.available[videoType] | |
153 | ||
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) | |
157 | continue | |
158 | } | |
159 | ||
160 | if (!encoders[encoder]) { | |
161 | this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags) | |
162 | continue | |
163 | } | |
164 | ||
165 | // An object containing available profiles for this encoder | |
166 | const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder] | |
167 | let builder = builderProfiles[this.profile] | |
168 | ||
169 | if (!builder) { | |
170 | this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags) | |
171 | builder = builderProfiles.default | |
172 | ||
173 | if (!builder) { | |
174 | this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags) | |
175 | continue | |
176 | } | |
177 | } | |
178 | ||
179 | const result = await builder( | |
180 | pick(options, [ | |
181 | 'input', | |
182 | 'canCopyAudio', | |
183 | 'canCopyVideo', | |
184 | 'resolution', | |
185 | 'inputBitrate', | |
186 | 'fps', | |
187 | 'inputRatio', | |
188 | 'streamNum' | |
189 | ]) | |
190 | ) | |
191 | ||
192 | return { | |
193 | result, | |
194 | ||
195 | // If we don't have output options, then copy the input stream | |
196 | encoder: result.copy === true | |
197 | ? 'copy' | |
198 | : encoder | |
199 | } | |
200 | } | |
201 | ||
202 | return null | |
203 | } | |
204 | ||
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 | |
209 | } | |
210 | ||
211 | const getAvailableEncodersPromise = promisify0(getAvailableEncoders) | |
212 | const availableFFmpegEncoders = await getAvailableEncodersPromise() | |
213 | ||
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) | |
219 | } | |
220 | } | |
221 | } | |
222 | ||
223 | const supportedEncoders = new Map<string, boolean>() | |
224 | ||
225 | for (const searchEncoder of searchEncoders) { | |
226 | supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined) | |
227 | } | |
228 | ||
229 | this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags }) | |
230 | ||
231 | FFmpegCommandWrapper.supportedEncoders = supportedEncoders | |
232 | return supportedEncoders | |
233 | } | |
234 | } |