diff options
Diffstat (limited to 'server/helpers/ffmpeg-utils.ts')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 781 |
1 files changed, 0 insertions, 781 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts deleted file mode 100644 index 78ee5fa7f..000000000 --- a/server/helpers/ffmpeg-utils.ts +++ /dev/null | |||
@@ -1,781 +0,0 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg' | ||
3 | import { readFile, remove, writeFile } from 'fs-extra' | ||
4 | import { dirname, join } from 'path' | ||
5 | import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants' | ||
6 | import { pick } from '@shared/core-utils' | ||
7 | import { | ||
8 | AvailableEncoders, | ||
9 | EncoderOptions, | ||
10 | EncoderOptionsBuilder, | ||
11 | EncoderOptionsBuilderParams, | ||
12 | EncoderProfile, | ||
13 | VideoResolution | ||
14 | } from '../../shared/models/videos' | ||
15 | import { CONFIG } from '../initializers/config' | ||
16 | import { execPromise, promisify0 } from './core-utils' | ||
17 | import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils' | ||
18 | import { processImage } from './image-utils' | ||
19 | import { logger, loggerTagsFactory } from './logger' | ||
20 | |||
21 | const lTags = loggerTagsFactory('ffmpeg') | ||
22 | |||
23 | /** | ||
24 | * | ||
25 | * Functions that run transcoding/muxing ffmpeg processes | ||
26 | * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts | ||
27 | * | ||
28 | */ | ||
29 | |||
30 | // --------------------------------------------------------------------------- | ||
31 | // Encoder options | ||
32 | // --------------------------------------------------------------------------- | ||
33 | |||
34 | type StreamType = 'audio' | 'video' | ||
35 | |||
36 | // --------------------------------------------------------------------------- | ||
37 | // Encoders support | ||
38 | // --------------------------------------------------------------------------- | ||
39 | |||
40 | // Detect supported encoders by ffmpeg | ||
41 | let supportedEncoders: Map<string, boolean> | ||
42 | async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> { | ||
43 | if (supportedEncoders !== undefined) { | ||
44 | return supportedEncoders | ||
45 | } | ||
46 | |||
47 | const getAvailableEncodersPromise = promisify0(getAvailableEncoders) | ||
48 | const availableFFmpegEncoders = await getAvailableEncodersPromise() | ||
49 | |||
50 | const searchEncoders = new Set<string>() | ||
51 | for (const type of [ 'live', 'vod' ]) { | ||
52 | for (const streamType of [ 'audio', 'video' ]) { | ||
53 | for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) { | ||
54 | searchEncoders.add(encoder) | ||
55 | } | ||
56 | } | ||
57 | } | ||
58 | |||
59 | supportedEncoders = new Map<string, boolean>() | ||
60 | |||
61 | for (const searchEncoder of searchEncoders) { | ||
62 | supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined) | ||
63 | } | ||
64 | |||
65 | logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() }) | ||
66 | |||
67 | return supportedEncoders | ||
68 | } | ||
69 | |||
70 | function resetSupportedEncoders () { | ||
71 | supportedEncoders = undefined | ||
72 | } | ||
73 | |||
74 | // --------------------------------------------------------------------------- | ||
75 | // Image manipulation | ||
76 | // --------------------------------------------------------------------------- | ||
77 | |||
78 | function convertWebPToJPG (path: string, destination: string): Promise<void> { | ||
79 | const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL }) | ||
80 | .output(destination) | ||
81 | |||
82 | return runCommand({ command, silent: true }) | ||
83 | } | ||
84 | |||
85 | function processGIF ( | ||
86 | path: string, | ||
87 | destination: string, | ||
88 | newSize: { width: number, height: number } | ||
89 | ): Promise<void> { | ||
90 | const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL }) | ||
91 | .fps(20) | ||
92 | .size(`${newSize.width}x${newSize.height}`) | ||
93 | .output(destination) | ||
94 | |||
95 | return runCommand({ command }) | ||
96 | } | ||
97 | |||
98 | async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { | ||
99 | const pendingImageName = 'pending-' + imageName | ||
100 | |||
101 | const options = { | ||
102 | filename: pendingImageName, | ||
103 | count: 1, | ||
104 | folder | ||
105 | } | ||
106 | |||
107 | const pendingImagePath = join(folder, pendingImageName) | ||
108 | |||
109 | try { | ||
110 | await new Promise<string>((res, rej) => { | ||
111 | ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL }) | ||
112 | .on('error', rej) | ||
113 | .on('end', () => res(imageName)) | ||
114 | .thumbnail(options) | ||
115 | }) | ||
116 | |||
117 | const destination = join(folder, imageName) | ||
118 | await processImage(pendingImagePath, destination, size) | ||
119 | } catch (err) { | ||
120 | logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() }) | ||
121 | |||
122 | try { | ||
123 | await remove(pendingImagePath) | ||
124 | } catch (err) { | ||
125 | logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() }) | ||
126 | } | ||
127 | } | ||
128 | } | ||
129 | |||
130 | // --------------------------------------------------------------------------- | ||
131 | // Transcode meta function | ||
132 | // --------------------------------------------------------------------------- | ||
133 | |||
134 | type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
135 | |||
136 | interface BaseTranscodeOptions { | ||
137 | type: TranscodeOptionsType | ||
138 | |||
139 | inputPath: string | ||
140 | outputPath: string | ||
141 | |||
142 | availableEncoders: AvailableEncoders | ||
143 | profile: string | ||
144 | |||
145 | resolution: number | ||
146 | |||
147 | isPortraitMode?: boolean | ||
148 | |||
149 | job?: Job | ||
150 | } | ||
151 | |||
152 | interface HLSTranscodeOptions extends BaseTranscodeOptions { | ||
153 | type: 'hls' | ||
154 | copyCodecs: boolean | ||
155 | hlsPlaylist: { | ||
156 | videoFilename: string | ||
157 | } | ||
158 | } | ||
159 | |||
160 | interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions { | ||
161 | type: 'hls-from-ts' | ||
162 | |||
163 | isAAC: boolean | ||
164 | |||
165 | hlsPlaylist: { | ||
166 | videoFilename: string | ||
167 | } | ||
168 | } | ||
169 | |||
170 | interface QuickTranscodeOptions extends BaseTranscodeOptions { | ||
171 | type: 'quick-transcode' | ||
172 | } | ||
173 | |||
174 | interface VideoTranscodeOptions extends BaseTranscodeOptions { | ||
175 | type: 'video' | ||
176 | } | ||
177 | |||
178 | interface MergeAudioTranscodeOptions extends BaseTranscodeOptions { | ||
179 | type: 'merge-audio' | ||
180 | audioPath: string | ||
181 | } | ||
182 | |||
183 | interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions { | ||
184 | type: 'only-audio' | ||
185 | } | ||
186 | |||
187 | type TranscodeOptions = | ||
188 | HLSTranscodeOptions | ||
189 | | HLSFromTSTranscodeOptions | ||
190 | | VideoTranscodeOptions | ||
191 | | MergeAudioTranscodeOptions | ||
192 | | OnlyAudioTranscodeOptions | ||
193 | | QuickTranscodeOptions | ||
194 | |||
195 | const builders: { | ||
196 | [ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand | ||
197 | } = { | ||
198 | 'quick-transcode': buildQuickTranscodeCommand, | ||
199 | 'hls': buildHLSVODCommand, | ||
200 | 'hls-from-ts': buildHLSVODFromTSCommand, | ||
201 | 'merge-audio': buildAudioMergeCommand, | ||
202 | 'only-audio': buildOnlyAudioCommand, | ||
203 | 'video': buildx264VODCommand | ||
204 | } | ||
205 | |||
206 | async function transcode (options: TranscodeOptions) { | ||
207 | logger.debug('Will run transcode.', { options, ...lTags() }) | ||
208 | |||
209 | let command = getFFmpeg(options.inputPath, 'vod') | ||
210 | .output(options.outputPath) | ||
211 | |||
212 | command = await builders[options.type](command, options) | ||
213 | |||
214 | await runCommand({ command, job: options.job }) | ||
215 | |||
216 | await fixHLSPlaylistIfNeeded(options) | ||
217 | } | ||
218 | |||
219 | // --------------------------------------------------------------------------- | ||
220 | // Live muxing/transcoding functions | ||
221 | // --------------------------------------------------------------------------- | ||
222 | |||
223 | async function getLiveTranscodingCommand (options: { | ||
224 | inputUrl: string | ||
225 | |||
226 | outPath: string | ||
227 | masterPlaylistName: string | ||
228 | |||
229 | resolutions: number[] | ||
230 | |||
231 | // Input information | ||
232 | fps: number | ||
233 | bitrate: number | ||
234 | ratio: number | ||
235 | |||
236 | availableEncoders: AvailableEncoders | ||
237 | profile: string | ||
238 | }) { | ||
239 | const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options | ||
240 | |||
241 | const command = getFFmpeg(inputUrl, 'live') | ||
242 | |||
243 | const varStreamMap: string[] = [] | ||
244 | |||
245 | const complexFilter: FilterSpecification[] = [ | ||
246 | { | ||
247 | inputs: '[v:0]', | ||
248 | filter: 'split', | ||
249 | options: resolutions.length, | ||
250 | outputs: resolutions.map(r => `vtemp${r}`) | ||
251 | } | ||
252 | ] | ||
253 | |||
254 | command.outputOption('-sc_threshold 0') | ||
255 | |||
256 | addDefaultEncoderGlobalParams({ command }) | ||
257 | |||
258 | for (let i = 0; i < resolutions.length; i++) { | ||
259 | const resolution = resolutions[i] | ||
260 | const resolutionFPS = computeFPS(fps, resolution) | ||
261 | |||
262 | const baseEncoderBuilderParams = { | ||
263 | input: inputUrl, | ||
264 | |||
265 | availableEncoders, | ||
266 | profile, | ||
267 | |||
268 | inputBitrate: bitrate, | ||
269 | inputRatio: ratio, | ||
270 | |||
271 | resolution, | ||
272 | fps: resolutionFPS, | ||
273 | |||
274 | streamNum: i, | ||
275 | videoType: 'live' as 'live' | ||
276 | } | ||
277 | |||
278 | { | ||
279 | const streamType: StreamType = 'video' | ||
280 | const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) | ||
281 | if (!builderResult) { | ||
282 | throw new Error('No available live video encoder found') | ||
283 | } | ||
284 | |||
285 | command.outputOption(`-map [vout${resolution}]`) | ||
286 | |||
287 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) | ||
288 | |||
289 | logger.debug( | ||
290 | 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile, | ||
291 | { builderResult, fps: resolutionFPS, resolution, ...lTags() } | ||
292 | ) | ||
293 | |||
294 | command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) | ||
295 | applyEncoderOptions(command, builderResult.result) | ||
296 | |||
297 | complexFilter.push({ | ||
298 | inputs: `vtemp${resolution}`, | ||
299 | filter: getScaleFilter(builderResult.result), | ||
300 | options: `w=-2:h=${resolution}`, | ||
301 | outputs: `vout${resolution}` | ||
302 | }) | ||
303 | } | ||
304 | |||
305 | { | ||
306 | const streamType: StreamType = 'audio' | ||
307 | const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) | ||
308 | if (!builderResult) { | ||
309 | throw new Error('No available live audio encoder found') | ||
310 | } | ||
311 | |||
312 | command.outputOption('-map a:0') | ||
313 | |||
314 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) | ||
315 | |||
316 | logger.debug( | ||
317 | 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile, | ||
318 | { builderResult, fps: resolutionFPS, resolution, ...lTags() } | ||
319 | ) | ||
320 | |||
321 | command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) | ||
322 | applyEncoderOptions(command, builderResult.result) | ||
323 | } | ||
324 | |||
325 | varStreamMap.push(`v:${i},a:${i}`) | ||
326 | } | ||
327 | |||
328 | command.complexFilter(complexFilter) | ||
329 | |||
330 | addDefaultLiveHLSParams(command, outPath, masterPlaylistName) | ||
331 | |||
332 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) | ||
333 | |||
334 | return command | ||
335 | } | ||
336 | |||
337 | function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) { | ||
338 | const command = getFFmpeg(inputUrl, 'live') | ||
339 | |||
340 | command.outputOption('-c:v copy') | ||
341 | command.outputOption('-c:a copy') | ||
342 | command.outputOption('-map 0:a?') | ||
343 | command.outputOption('-map 0:v?') | ||
344 | |||
345 | addDefaultLiveHLSParams(command, outPath, masterPlaylistName) | ||
346 | |||
347 | return command | ||
348 | } | ||
349 | |||
350 | function buildStreamSuffix (base: string, streamNum?: number) { | ||
351 | if (streamNum !== undefined) { | ||
352 | return `${base}:${streamNum}` | ||
353 | } | ||
354 | |||
355 | return base | ||
356 | } | ||
357 | |||
358 | // --------------------------------------------------------------------------- | ||
359 | // Default options | ||
360 | // --------------------------------------------------------------------------- | ||
361 | |||
362 | function addDefaultEncoderGlobalParams (options: { | ||
363 | command: FfmpegCommand | ||
364 | }) { | ||
365 | const { command } = options | ||
366 | |||
367 | // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 | ||
368 | command.outputOption('-max_muxing_queue_size 1024') | ||
369 | // strip all metadata | ||
370 | .outputOption('-map_metadata -1') | ||
371 | // allows import of source material with incompatible pixel formats (e.g. MJPEG video) | ||
372 | .outputOption('-pix_fmt yuv420p') | ||
373 | } | ||
374 | |||
375 | function addDefaultEncoderParams (options: { | ||
376 | command: FfmpegCommand | ||
377 | encoder: 'libx264' | string | ||
378 | streamNum?: number | ||
379 | fps?: number | ||
380 | }) { | ||
381 | const { command, encoder, fps, streamNum } = options | ||
382 | |||
383 | if (encoder === 'libx264') { | ||
384 | // 3.1 is the minimal resource allocation for our highest supported resolution | ||
385 | command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1') | ||
386 | |||
387 | if (fps) { | ||
388 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | ||
389 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | ||
390 | // https://superuser.com/a/908325 | ||
391 | command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2)) | ||
392 | } | ||
393 | } | ||
394 | } | ||
395 | |||
396 | function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) { | ||
397 | command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) | ||
398 | command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) | ||
399 | command.outputOption('-hls_flags delete_segments+independent_segments') | ||
400 | command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) | ||
401 | command.outputOption('-master_pl_name ' + masterPlaylistName) | ||
402 | command.outputOption(`-f hls`) | ||
403 | |||
404 | command.output(join(outPath, '%v.m3u8')) | ||
405 | } | ||
406 | |||
407 | // --------------------------------------------------------------------------- | ||
408 | // Transcode VOD command builders | ||
409 | // --------------------------------------------------------------------------- | ||
410 | |||
411 | async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) { | ||
412 | let fps = await getVideoFileFPS(options.inputPath) | ||
413 | fps = computeFPS(fps, options.resolution) | ||
414 | |||
415 | let scaleFilterValue: string | ||
416 | |||
417 | if (options.resolution !== undefined) { | ||
418 | scaleFilterValue = options.isPortraitMode === true | ||
419 | ? `w=${options.resolution}:h=-2` | ||
420 | : `w=-2:h=${options.resolution}` | ||
421 | } | ||
422 | |||
423 | command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue }) | ||
424 | |||
425 | return command | ||
426 | } | ||
427 | |||
428 | async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) { | ||
429 | command = command.loop(undefined) | ||
430 | |||
431 | const scaleFilterValue = getScaleCleanerValue() | ||
432 | command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue }) | ||
433 | |||
434 | command.outputOption('-preset:v veryfast') | ||
435 | |||
436 | command = command.input(options.audioPath) | ||
437 | .outputOption('-tune stillimage') | ||
438 | .outputOption('-shortest') | ||
439 | |||
440 | return command | ||
441 | } | ||
442 | |||
443 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
444 | command = presetOnlyAudio(command) | ||
445 | |||
446 | return command | ||
447 | } | ||
448 | |||
449 | function buildQuickTranscodeCommand (command: FfmpegCommand) { | ||
450 | command = presetCopy(command) | ||
451 | |||
452 | command = command.outputOption('-map_metadata -1') // strip all metadata | ||
453 | .outputOption('-movflags faststart') | ||
454 | |||
455 | return command | ||
456 | } | ||
457 | |||
458 | function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) { | ||
459 | return command.outputOption('-hls_time 4') | ||
460 | .outputOption('-hls_list_size 0') | ||
461 | .outputOption('-hls_playlist_type vod') | ||
462 | .outputOption('-hls_segment_filename ' + outputPath) | ||
463 | .outputOption('-hls_segment_type fmp4') | ||
464 | .outputOption('-f hls') | ||
465 | .outputOption('-hls_flags single_file') | ||
466 | } | ||
467 | |||
468 | async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) { | ||
469 | const videoPath = getHLSVideoPath(options) | ||
470 | |||
471 | if (options.copyCodecs) command = presetCopy(command) | ||
472 | else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) | ||
473 | else command = await buildx264VODCommand(command, options) | ||
474 | |||
475 | addCommonHLSVODCommandOptions(command, videoPath) | ||
476 | |||
477 | return command | ||
478 | } | ||
479 | |||
480 | function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) { | ||
481 | const videoPath = getHLSVideoPath(options) | ||
482 | |||
483 | command.outputOption('-c copy') | ||
484 | |||
485 | if (options.isAAC) { | ||
486 | // Required for example when copying an AAC stream from an MPEG-TS | ||
487 | // Since it's a bitstream filter, we don't need to reencode the audio | ||
488 | command.outputOption('-bsf:a aac_adtstoasc') | ||
489 | } | ||
490 | |||
491 | addCommonHLSVODCommandOptions(command, videoPath) | ||
492 | |||
493 | return command | ||
494 | } | ||
495 | |||
496 | async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { | ||
497 | if (options.type !== 'hls' && options.type !== 'hls-from-ts') return | ||
498 | |||
499 | const fileContent = await readFile(options.outputPath) | ||
500 | |||
501 | const videoFileName = options.hlsPlaylist.videoFilename | ||
502 | const videoFilePath = getHLSVideoPath(options) | ||
503 | |||
504 | // Fix wrong mapping with some ffmpeg versions | ||
505 | const newContent = fileContent.toString() | ||
506 | .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) | ||
507 | |||
508 | await writeFile(options.outputPath, newContent) | ||
509 | } | ||
510 | |||
511 | function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { | ||
512 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
513 | } | ||
514 | |||
515 | // --------------------------------------------------------------------------- | ||
516 | // Transcoding presets | ||
517 | // --------------------------------------------------------------------------- | ||
518 | |||
519 | // Run encoder builder depending on available encoders | ||
520 | // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one | ||
521 | // If the default one does not exist, check the next encoder | ||
522 | async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & { | ||
523 | streamType: 'video' | 'audio' | ||
524 | input: string | ||
525 | |||
526 | availableEncoders: AvailableEncoders | ||
527 | profile: string | ||
528 | |||
529 | videoType: 'vod' | 'live' | ||
530 | }) { | ||
531 | const { availableEncoders, profile, streamType, videoType } = options | ||
532 | |||
533 | const encodersToTry = availableEncoders.encodersToTry[videoType][streamType] | ||
534 | const encoders = availableEncoders.available[videoType] | ||
535 | |||
536 | for (const encoder of encodersToTry) { | ||
537 | if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) { | ||
538 | logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags()) | ||
539 | continue | ||
540 | } | ||
541 | |||
542 | if (!encoders[encoder]) { | ||
543 | logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags()) | ||
544 | continue | ||
545 | } | ||
546 | |||
547 | // An object containing available profiles for this encoder | ||
548 | const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder] | ||
549 | let builder = builderProfiles[profile] | ||
550 | |||
551 | if (!builder) { | ||
552 | logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags()) | ||
553 | builder = builderProfiles.default | ||
554 | |||
555 | if (!builder) { | ||
556 | logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags()) | ||
557 | continue | ||
558 | } | ||
559 | } | ||
560 | |||
561 | const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ])) | ||
562 | |||
563 | return { | ||
564 | result, | ||
565 | |||
566 | // If we don't have output options, then copy the input stream | ||
567 | encoder: result.copy === true | ||
568 | ? 'copy' | ||
569 | : encoder | ||
570 | } | ||
571 | } | ||
572 | |||
573 | return null | ||
574 | } | ||
575 | |||
576 | async function presetVideo (options: { | ||
577 | command: FfmpegCommand | ||
578 | input: string | ||
579 | transcodeOptions: TranscodeOptions | ||
580 | fps?: number | ||
581 | scaleFilterValue?: string | ||
582 | }) { | ||
583 | const { command, input, transcodeOptions, fps, scaleFilterValue } = options | ||
584 | |||
585 | let localCommand = command | ||
586 | .format('mp4') | ||
587 | .outputOption('-movflags faststart') | ||
588 | |||
589 | addDefaultEncoderGlobalParams({ command }) | ||
590 | |||
591 | const probe = await ffprobePromise(input) | ||
592 | |||
593 | // Audio encoder | ||
594 | const parsedAudio = await getAudioStream(input, probe) | ||
595 | const bitrate = await getVideoFileBitrate(input, probe) | ||
596 | const { ratio } = await getVideoFileResolution(input, probe) | ||
597 | |||
598 | let streamsToProcess: StreamType[] = [ 'audio', 'video' ] | ||
599 | |||
600 | if (!parsedAudio.audioStream) { | ||
601 | localCommand = localCommand.noAudio() | ||
602 | streamsToProcess = [ 'video' ] | ||
603 | } | ||
604 | |||
605 | for (const streamType of streamsToProcess) { | ||
606 | const { profile, resolution, availableEncoders } = transcodeOptions | ||
607 | |||
608 | const builderResult = await getEncoderBuilderResult({ | ||
609 | streamType, | ||
610 | input, | ||
611 | resolution, | ||
612 | availableEncoders, | ||
613 | profile, | ||
614 | fps, | ||
615 | inputBitrate: bitrate, | ||
616 | inputRatio: ratio, | ||
617 | videoType: 'vod' as 'vod' | ||
618 | }) | ||
619 | |||
620 | if (!builderResult) { | ||
621 | throw new Error('No available encoder found for stream ' + streamType) | ||
622 | } | ||
623 | |||
624 | logger.debug( | ||
625 | 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.', | ||
626 | builderResult.encoder, streamType, input, profile, | ||
627 | { builderResult, resolution, fps, ...lTags() } | ||
628 | ) | ||
629 | |||
630 | if (streamType === 'video') { | ||
631 | localCommand.videoCodec(builderResult.encoder) | ||
632 | |||
633 | if (scaleFilterValue) { | ||
634 | localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`) | ||
635 | } | ||
636 | } else if (streamType === 'audio') { | ||
637 | localCommand.audioCodec(builderResult.encoder) | ||
638 | } | ||
639 | |||
640 | applyEncoderOptions(localCommand, builderResult.result) | ||
641 | addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps }) | ||
642 | } | ||
643 | |||
644 | return localCommand | ||
645 | } | ||
646 | |||
647 | function presetCopy (command: FfmpegCommand): FfmpegCommand { | ||
648 | return command | ||
649 | .format('mp4') | ||
650 | .videoCodec('copy') | ||
651 | .audioCodec('copy') | ||
652 | } | ||
653 | |||
654 | function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand { | ||
655 | return command | ||
656 | .format('mp4') | ||
657 | .audioCodec('copy') | ||
658 | .noVideo() | ||
659 | } | ||
660 | |||
661 | function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand { | ||
662 | return command | ||
663 | .inputOptions(options.inputOptions ?? []) | ||
664 | .outputOptions(options.outputOptions ?? []) | ||
665 | } | ||
666 | |||
667 | function getScaleFilter (options: EncoderOptions): string { | ||
668 | if (options.scaleFilter) return options.scaleFilter.name | ||
669 | |||
670 | return 'scale' | ||
671 | } | ||
672 | |||
673 | // --------------------------------------------------------------------------- | ||
674 | // Utils | ||
675 | // --------------------------------------------------------------------------- | ||
676 | |||
677 | function getFFmpeg (input: string, type: 'live' | 'vod') { | ||
678 | // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems | ||
679 | const command = ffmpeg(input, { | ||
680 | niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD, | ||
681 | cwd: CONFIG.STORAGE.TMP_DIR | ||
682 | }) | ||
683 | |||
684 | const threads = type === 'live' | ||
685 | ? CONFIG.LIVE.TRANSCODING.THREADS | ||
686 | : CONFIG.TRANSCODING.THREADS | ||
687 | |||
688 | if (threads > 0) { | ||
689 | // If we don't set any threads ffmpeg will chose automatically | ||
690 | command.outputOption('-threads ' + threads) | ||
691 | } | ||
692 | |||
693 | return command | ||
694 | } | ||
695 | |||
696 | function getFFmpegVersion () { | ||
697 | return new Promise<string>((res, rej) => { | ||
698 | (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => { | ||
699 | if (err) return rej(err) | ||
700 | if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path')) | ||
701 | |||
702 | return execPromise(`${ffmpegPath} -version`) | ||
703 | .then(stdout => { | ||
704 | const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/) | ||
705 | if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`)) | ||
706 | |||
707 | // Fix ffmpeg version that does not include patch version (4.4 for example) | ||
708 | let version = parsed[1] | ||
709 | if (version.match(/^\d+\.\d+$/)) { | ||
710 | version += '.0' | ||
711 | } | ||
712 | |||
713 | return res(version) | ||
714 | }) | ||
715 | .catch(err => rej(err)) | ||
716 | }) | ||
717 | }) | ||
718 | } | ||
719 | |||
720 | async function runCommand (options: { | ||
721 | command: FfmpegCommand | ||
722 | silent?: boolean // false | ||
723 | job?: Job | ||
724 | }) { | ||
725 | const { command, silent = false, job } = options | ||
726 | |||
727 | return new Promise<void>((res, rej) => { | ||
728 | let shellCommand: string | ||
729 | |||
730 | command.on('start', cmdline => { shellCommand = cmdline }) | ||
731 | |||
732 | command.on('error', (err, stdout, stderr) => { | ||
733 | if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() }) | ||
734 | |||
735 | rej(err) | ||
736 | }) | ||
737 | |||
738 | command.on('end', (stdout, stderr) => { | ||
739 | logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() }) | ||
740 | |||
741 | res() | ||
742 | }) | ||
743 | |||
744 | if (job) { | ||
745 | command.on('progress', progress => { | ||
746 | if (!progress.percent) return | ||
747 | |||
748 | job.progress(Math.round(progress.percent)) | ||
749 | .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() })) | ||
750 | }) | ||
751 | } | ||
752 | |||
753 | command.run() | ||
754 | }) | ||
755 | } | ||
756 | |||
757 | // Avoid "height not divisible by 2" error | ||
758 | function getScaleCleanerValue () { | ||
759 | return 'trunc(iw/2)*2:trunc(ih/2)*2' | ||
760 | } | ||
761 | |||
762 | // --------------------------------------------------------------------------- | ||
763 | |||
764 | export { | ||
765 | getLiveTranscodingCommand, | ||
766 | getLiveMuxingCommand, | ||
767 | buildStreamSuffix, | ||
768 | convertWebPToJPG, | ||
769 | processGIF, | ||
770 | generateImageFromVideoFile, | ||
771 | TranscodeOptions, | ||
772 | TranscodeOptionsType, | ||
773 | transcode, | ||
774 | runCommand, | ||
775 | getFFmpegVersion, | ||
776 | |||
777 | resetSupportedEncoders, | ||
778 | |||
779 | // builders | ||
780 | buildx264VODCommand | ||
781 | } | ||