diff options
Diffstat (limited to 'shared/ffmpeg/ffmpeg-command-wrapper.ts')
-rw-r--r-- | shared/ffmpeg/ffmpeg-command-wrapper.ts | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/shared/ffmpeg/ffmpeg-command-wrapper.ts b/shared/ffmpeg/ffmpeg-command-wrapper.ts new file mode 100644 index 000000000..7a8c19d4b --- /dev/null +++ b/shared/ffmpeg/ffmpeg-command-wrapper.ts | |||
@@ -0,0 +1,234 @@ | |||
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 | } | ||