aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared/ffmpeg/ffmpeg-command-wrapper.ts
diff options
context:
space:
mode:
Diffstat (limited to 'shared/ffmpeg/ffmpeg-command-wrapper.ts')
-rw-r--r--shared/ffmpeg/ffmpeg-command-wrapper.ts234
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 @@
1import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick, promisify0 } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4
5type 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
12export 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
26export 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}