aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/helpers/ffmpeg-utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'server/helpers/ffmpeg-utils.ts')
-rw-r--r--server/helpers/ffmpeg-utils.ts310
1 files changed, 33 insertions, 277 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 66b9d2e44..df3926658 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,201 +1,14 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { readFile, remove, writeFile } from 'fs-extra' 2import { readFile, remove, writeFile } from 'fs-extra'
3import { dirname, join } from 'path' 3import { dirname, join } from 'path'
4import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata' 4import { getTargetBitrate, VideoResolution } from '../../shared/models/videos'
5import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
6import { checkFFmpegEncoders } from '../initializers/checker-before-init' 5import { checkFFmpegEncoders } from '../initializers/checker-before-init'
7import { CONFIG } from '../initializers/config' 6import { CONFIG } from '../initializers/config'
8import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' 7import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
8import { getAudioStream, getClosestFramerateStandard, getMaxAudioBitrate, getVideoFileFPS } from './ffprobe-utils'
9import { processImage } from './image-utils' 9import { processImage } from './image-utils'
10import { logger } from './logger' 10import { logger } from './logger'
11 11
12/**
13 * A toolbox to play with audio
14 */
15namespace audio {
16 export const get = (videoPath: string) => {
17 // without position, ffprobe considers the last input only
18 // we make it consider the first input only
19 // if you pass a file path to pos, then ffprobe acts on that file directly
20 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
21
22 function parseFfprobe (err: any, data: ffmpeg.FfprobeData) {
23 if (err) return rej(err)
24
25 if ('streams' in data) {
26 const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
27 if (audioStream) {
28 return res({
29 absolutePath: data.format.filename,
30 audioStream
31 })
32 }
33 }
34
35 return res({ absolutePath: data.format.filename })
36 }
37
38 return ffmpeg.ffprobe(videoPath, parseFfprobe)
39 })
40 }
41
42 export namespace bitrate {
43 const baseKbitrate = 384
44
45 const toBits = (kbits: number) => kbits * 8000
46
47 export const aac = (bitrate: number): number => {
48 switch (true) {
49 case bitrate > toBits(baseKbitrate):
50 return baseKbitrate
51
52 default:
53 return -1 // we interpret it as a signal to copy the audio stream as is
54 }
55 }
56
57 export const mp3 = (bitrate: number): number => {
58 /*
59 a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
60 That's why, when using aac, we can go to lower kbit/sec. The equivalences
61 made here are not made to be accurate, especially with good mp3 encoders.
62 */
63 switch (true) {
64 case bitrate <= toBits(192):
65 return 128
66
67 case bitrate <= toBits(384):
68 return 256
69
70 default:
71 return baseKbitrate
72 }
73 }
74 }
75}
76
77function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
78 const configResolutions = type === 'vod'
79 ? CONFIG.TRANSCODING.RESOLUTIONS
80 : CONFIG.LIVE.TRANSCODING.RESOLUTIONS
81
82 const resolutionsEnabled: number[] = []
83
84 // Put in the order we want to proceed jobs
85 const resolutions = [
86 VideoResolution.H_NOVIDEO,
87 VideoResolution.H_480P,
88 VideoResolution.H_360P,
89 VideoResolution.H_720P,
90 VideoResolution.H_240P,
91 VideoResolution.H_1080P,
92 VideoResolution.H_4K
93 ]
94
95 for (const resolution of resolutions) {
96 if (configResolutions[resolution + 'p'] === true && videoFileResolution > resolution) {
97 resolutionsEnabled.push(resolution)
98 }
99 }
100
101 return resolutionsEnabled
102}
103
104async function getVideoStreamSize (path: string) {
105 const videoStream = await getVideoStreamFromFile(path)
106
107 return videoStream === null
108 ? { width: 0, height: 0 }
109 : { width: videoStream.width, height: videoStream.height }
110}
111
112async function getVideoStreamCodec (path: string) {
113 const videoStream = await getVideoStreamFromFile(path)
114
115 if (!videoStream) return ''
116
117 const videoCodec = videoStream.codec_tag_string
118
119 const baseProfileMatrix = {
120 High: '6400',
121 Main: '4D40',
122 Baseline: '42E0'
123 }
124
125 let baseProfile = baseProfileMatrix[videoStream.profile]
126 if (!baseProfile) {
127 logger.warn('Cannot get video profile codec of %s.', path, { videoStream })
128 baseProfile = baseProfileMatrix['High'] // Fallback
129 }
130
131 let level = videoStream.level.toString(16)
132 if (level.length === 1) level = `0${level}`
133
134 return `${videoCodec}.${baseProfile}${level}`
135}
136
137async function getAudioStreamCodec (path: string) {
138 const { audioStream } = await audio.get(path)
139
140 if (!audioStream) return ''
141
142 const audioCodec = audioStream.codec_name
143 if (audioCodec === 'aac') return 'mp4a.40.2'
144
145 logger.warn('Cannot get audio codec of %s.', path, { audioStream })
146
147 return 'mp4a.40.2' // Fallback
148}
149
150async function getVideoFileResolution (path: string) {
151 const size = await getVideoStreamSize(path)
152
153 return {
154 videoFileResolution: Math.min(size.height, size.width),
155 isPortraitMode: size.height > size.width
156 }
157}
158
159async function getVideoFileFPS (path: string) {
160 const videoStream = await getVideoStreamFromFile(path)
161 if (videoStream === null) return 0
162
163 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
164 const valuesText: string = videoStream[key]
165 if (!valuesText) continue
166
167 const [ frames, seconds ] = valuesText.split('/')
168 if (!frames || !seconds) continue
169
170 const result = parseInt(frames, 10) / parseInt(seconds, 10)
171 if (result > 0) return Math.round(result)
172 }
173
174 return 0
175}
176
177async function getMetadataFromFile <T> (path: string, cb = metadata => metadata) {
178 return new Promise<T>((res, rej) => {
179 ffmpeg.ffprobe(path, (err, metadata) => {
180 if (err) return rej(err)
181
182 return res(cb(new VideoFileMetadata(metadata)))
183 })
184 })
185}
186
187async function getVideoFileBitrate (path: string) {
188 return getMetadataFromFile<number>(path, metadata => metadata.format.bit_rate)
189}
190
191function getDurationFromVideoFile (path: string) {
192 return getMetadataFromFile<number>(path, metadata => Math.floor(metadata.format.duration))
193}
194
195function getVideoStreamFromFile (path: string) {
196 return getMetadataFromFile<any>(path, metadata => metadata.streams.find(s => s.codec_type === 'video') || null)
197}
198
199async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { 12async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
200 const pendingImageName = 'pending-' + imageName 13 const pendingImageName = 'pending-' + imageName
201 14
@@ -228,6 +41,10 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
228 } 41 }
229} 42}
230 43
44// ---------------------------------------------------------------------------
45// Transcode meta function
46// ---------------------------------------------------------------------------
47
231type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' 48type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
232 49
233interface BaseTranscodeOptions { 50interface BaseTranscodeOptions {
@@ -270,72 +87,27 @@ type TranscodeOptions =
270 | OnlyAudioTranscodeOptions 87 | OnlyAudioTranscodeOptions
271 | QuickTranscodeOptions 88 | QuickTranscodeOptions
272 89
273function transcode (options: TranscodeOptions) { 90const builders: {
91 [ type in TranscodeOptionsType ]: (c: ffmpeg.FfmpegCommand, o?: TranscodeOptions) => Promise<ffmpeg.FfmpegCommand> | ffmpeg.FfmpegCommand
92} = {
93 'quick-transcode': buildQuickTranscodeCommand,
94 'hls': buildHLSVODCommand,
95 'merge-audio': buildAudioMergeCommand,
96 'only-audio': buildOnlyAudioCommand,
97 'video': buildx264Command
98}
99
100async function transcode (options: TranscodeOptions) {
274 logger.debug('Will run transcode.', { options }) 101 logger.debug('Will run transcode.', { options })
275 102
276 return new Promise<void>(async (res, rej) => { 103 let command = getFFmpeg(options.inputPath)
277 try { 104 .output(options.outputPath)
278 let command = getFFmpeg(options.inputPath)
279 .output(options.outputPath)
280
281 if (options.type === 'quick-transcode') {
282 command = buildQuickTranscodeCommand(command)
283 } else if (options.type === 'hls') {
284 command = await buildHLSVODCommand(command, options)
285 } else if (options.type === 'merge-audio') {
286 command = await buildAudioMergeCommand(command, options)
287 } else if (options.type === 'only-audio') {
288 command = buildOnlyAudioCommand(command, options)
289 } else {
290 command = await buildx264Command(command, options)
291 }
292
293 command
294 .on('error', (err, stdout, stderr) => {
295 logger.error('Error in transcoding job.', { stdout, stderr })
296 return rej(err)
297 })
298 .on('end', () => {
299 return fixHLSPlaylistIfNeeded(options)
300 .then(() => res())
301 .catch(err => rej(err))
302 })
303 .run()
304 } catch (err) {
305 return rej(err)
306 }
307 })
308}
309 105
310async function canDoQuickTranscode (path: string): Promise<boolean> { 106 command = await builders[options.type](command, options)
311 // NOTE: This could be optimized by running ffprobe only once (but it runs fast anyway)
312 const videoStream = await getVideoStreamFromFile(path)
313 const parsedAudio = await audio.get(path)
314 const fps = await getVideoFileFPS(path)
315 const bitRate = await getVideoFileBitrate(path)
316 const resolution = await getVideoFileResolution(path)
317
318 // check video params
319 if (videoStream == null) return false
320 if (videoStream['codec_name'] !== 'h264') return false
321 if (videoStream['pix_fmt'] !== 'yuv420p') return false
322 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
323 if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false
324
325 // check audio params (if audio stream exists)
326 if (parsedAudio.audioStream) {
327 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
328
329 const maxAudioBitrate = audio.bitrate['aac'](parsedAudio.audioStream['bit_rate'])
330 if (maxAudioBitrate !== -1 && parsedAudio.audioStream['bit_rate'] > maxAudioBitrate) return false
331 }
332 107
333 return true 108 await runCommand(command)
334}
335 109
336function getClosestFramerateStandard (fps: number, type: 'HD_STANDARD' | 'STANDARD'): number { 110 await fixHLSPlaylistIfNeeded(options)
337 return VIDEO_TRANSCODING_FPS[type].slice(0)
338 .sort((a, b) => fps % a - fps % b)[0]
339} 111}
340 112
341function convertWebPToJPG (path: string, destination: string): Promise<void> { 113function convertWebPToJPG (path: string, destination: string): Promise<void> {
@@ -484,12 +256,11 @@ async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: s
484} 256}
485 257
486async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) { 258async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) {
487 command.run()
488
489 return new Promise<string>((res, rej) => { 259 return new Promise<string>((res, rej) => {
490 command.on('error', err => { 260 command.on('error', (err, stdout, stderr) => {
491 if (onEnd) onEnd() 261 if (onEnd) onEnd()
492 262
263 logger.error('Error in transcoding job.', { stdout, stderr })
493 rej(err) 264 rej(err)
494 }) 265 })
495 266
@@ -498,32 +269,23 @@ async function runCommand (command: ffmpeg.FfmpegCommand, onEnd?: Function) {
498 269
499 res() 270 res()
500 }) 271 })
272
273 command.run()
501 }) 274 })
502} 275}
503 276
504// --------------------------------------------------------------------------- 277// ---------------------------------------------------------------------------
505 278
506export { 279export {
507 getVideoStreamCodec,
508 getAudioStreamCodec,
509 runLiveMuxing, 280 runLiveMuxing,
510 convertWebPToJPG, 281 convertWebPToJPG,
511 processGIF, 282 processGIF,
512 getVideoStreamSize,
513 getVideoFileResolution,
514 getMetadataFromFile,
515 getDurationFromVideoFile,
516 runLiveTranscoding, 283 runLiveTranscoding,
517 generateImageFromVideoFile, 284 generateImageFromVideoFile,
518 TranscodeOptions, 285 TranscodeOptions,
519 TranscodeOptionsType, 286 TranscodeOptionsType,
520 transcode, 287 transcode,
521 getVideoFileFPS, 288 hlsPlaylistToFragmentedMP4
522 computeResolutionsToTranscode,
523 audio,
524 hlsPlaylistToFragmentedMP4,
525 getVideoFileBitrate,
526 canDoQuickTranscode
527} 289}
528 290
529// --------------------------------------------------------------------------- 291// ---------------------------------------------------------------------------
@@ -595,7 +357,7 @@ async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: M
595 return command 357 return command
596} 358}
597 359
598function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, options: OnlyAudioTranscodeOptions) { 360function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
599 command = presetOnlyAudio(command) 361 command = presetOnlyAudio(command)
600 362
601 return command 363 return command
@@ -684,7 +446,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
684 446
685 addDefaultX264Params(localCommand) 447 addDefaultX264Params(localCommand)
686 448
687 const parsedAudio = await audio.get(input) 449 const parsedAudio = await getAudioStream(input)
688 450
689 if (!parsedAudio.audioStream) { 451 if (!parsedAudio.audioStream) {
690 localCommand = localCommand.noAudio() 452 localCommand = localCommand.noAudio()
@@ -699,22 +461,16 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
699 461
700 const audioCodecName = parsedAudio.audioStream['codec_name'] 462 const audioCodecName = parsedAudio.audioStream['codec_name']
701 463
702 if (audio.bitrate[audioCodecName]) { 464 const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate)
703 const bitrate = audio.bitrate[audioCodecName](parsedAudio.audioStream['bit_rate']) 465
704 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) 466 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
705 }
706 } 467 }
707 468
708 if (fps) { 469 if (fps) {
709 // Constrained Encoding (VBV) 470 // Constrained Encoding (VBV)
710 // https://slhck.info/video/2017/03/01/rate-control.html 471 // https://slhck.info/video/2017/03/01/rate-control.html
711 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate 472 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
712 let targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) 473 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
713
714 // Don't transcode to an higher bitrate than the original file
715 const fileBitrate = await getVideoFileBitrate(input)
716 targetBitrate = Math.min(targetBitrate, fileBitrate)
717
718 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ]) 474 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
719 475
720 // Keyframe interval of 2 seconds for faster seeking and resolution switching. 476 // Keyframe interval of 2 seconds for faster seeking and resolution switching.