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.ts177
1 files changed, 116 insertions, 61 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 76b744de8..c180da832 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,6 +1,6 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { dirname, join } from 'path' 2import { dirname, join } from 'path'
3import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' 3import { getTargetBitrate, getMaxBitrate, VideoResolution } from '../../shared/models/videos'
4import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' 4import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
5import { processImage } from './image-utils' 5import { processImage } from './image-utils'
6import { logger } from './logger' 6import { logger } from './logger'
@@ -31,7 +31,7 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
31} 31}
32 32
33async function getVideoFileSize (path: string) { 33async function getVideoFileSize (path: string) {
34 const videoStream = await getVideoFileStream(path) 34 const videoStream = await getVideoStreamFromFile(path)
35 35
36 return { 36 return {
37 width: videoStream.width, 37 width: videoStream.width,
@@ -49,7 +49,7 @@ async function getVideoFileResolution (path: string) {
49} 49}
50 50
51async function getVideoFileFPS (path: string) { 51async function getVideoFileFPS (path: string) {
52 const videoStream = await getVideoFileStream(path) 52 const videoStream = await getVideoStreamFromFile(path)
53 53
54 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { 54 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
55 const valuesText: string = videoStream[key] 55 const valuesText: string = videoStream[key]
@@ -117,25 +117,50 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
117 } 117 }
118} 118}
119 119
120type TranscodeOptions = { 120type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio'
121
122interface BaseTranscodeOptions {
123 type: TranscodeOptionsType
121 inputPath: string 124 inputPath: string
122 outputPath: string 125 outputPath: string
123 resolution: VideoResolution 126 resolution: VideoResolution
124 isPortraitMode?: boolean 127 isPortraitMode?: boolean
128}
125 129
126 hlsPlaylist?: { 130interface HLSTranscodeOptions extends BaseTranscodeOptions {
131 type: 'hls'
132 hlsPlaylist: {
127 videoFilename: string 133 videoFilename: string
128 } 134 }
129} 135}
130 136
137interface QuickTranscodeOptions extends BaseTranscodeOptions {
138 type: 'quick-transcode'
139}
140
141interface VideoTranscodeOptions extends BaseTranscodeOptions {
142 type: 'video'
143}
144
145interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
146 type: 'merge-audio'
147 audioPath: string
148}
149
150type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions
151
131function transcode (options: TranscodeOptions) { 152function transcode (options: TranscodeOptions) {
132 return new Promise<void>(async (res, rej) => { 153 return new Promise<void>(async (res, rej) => {
133 try { 154 try {
134 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) 155 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
135 .output(options.outputPath) 156 .output(options.outputPath)
136 157
137 if (options.hlsPlaylist) { 158 if (options.type === 'quick-transcode') {
159 command = await buildQuickTranscodeCommand(command)
160 } else if (options.type === 'hls') {
138 command = await buildHLSCommand(command, options) 161 command = await buildHLSCommand(command, options)
162 } else if (options.type === 'merge-audio') {
163 command = await buildAudioMergeCommand(command, options)
139 } else { 164 } else {
140 command = await buildx264Command(command, options) 165 command = await buildx264Command(command, options)
141 } 166 }
@@ -151,7 +176,7 @@ function transcode (options: TranscodeOptions) {
151 return rej(err) 176 return rej(err)
152 }) 177 })
153 .on('end', () => { 178 .on('end', () => {
154 return onTranscodingSuccess(options) 179 return fixHLSPlaylistIfNeeded(options)
155 .then(() => res()) 180 .then(() => res())
156 .catch(err => rej(err)) 181 .catch(err => rej(err))
157 }) 182 })
@@ -162,6 +187,30 @@ function transcode (options: TranscodeOptions) {
162 }) 187 })
163} 188}
164 189
190async function canDoQuickTranscode (path: string): Promise<boolean> {
191 // NOTE: This could be optimized by running ffprobe only once (but it runs fast anyway)
192 const videoStream = await getVideoStreamFromFile(path)
193 const parsedAudio = await audio.get(path)
194 const fps = await getVideoFileFPS(path)
195 const bitRate = await getVideoFileBitrate(path)
196 const resolution = await getVideoFileResolution(path)
197
198 // check video params
199 if (videoStream[ 'codec_name' ] !== 'h264') return false
200 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
201 if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false
202
203 // check audio params (if audio stream exists)
204 if (parsedAudio.audioStream) {
205 if (parsedAudio.audioStream[ 'codec_name' ] !== 'aac') return false
206
207 const maxAudioBitrate = audio.bitrate[ 'aac' ](parsedAudio.audioStream[ 'bit_rate' ])
208 if (maxAudioBitrate !== -1 && parsedAudio.audioStream[ 'bit_rate' ] > maxAudioBitrate) return false
209 }
210
211 return true
212}
213
165// --------------------------------------------------------------------------- 214// ---------------------------------------------------------------------------
166 215
167export { 216export {
@@ -169,16 +218,19 @@ export {
169 getVideoFileResolution, 218 getVideoFileResolution,
170 getDurationFromVideoFile, 219 getDurationFromVideoFile,
171 generateImageFromVideoFile, 220 generateImageFromVideoFile,
221 TranscodeOptions,
222 TranscodeOptionsType,
172 transcode, 223 transcode,
173 getVideoFileFPS, 224 getVideoFileFPS,
174 computeResolutionsToTranscode, 225 computeResolutionsToTranscode,
175 audio, 226 audio,
176 getVideoFileBitrate 227 getVideoFileBitrate,
228 canDoQuickTranscode
177} 229}
178 230
179// --------------------------------------------------------------------------- 231// ---------------------------------------------------------------------------
180 232
181async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 233async function buildx264Command (command: ffmpeg.FfmpegCommand, options: VideoTranscodeOptions) {
182 let fps = await getVideoFileFPS(options.inputPath) 234 let fps = await getVideoFileFPS(options.inputPath)
183 // On small/medium resolutions, limit FPS 235 // On small/medium resolutions, limit FPS
184 if ( 236 if (
@@ -189,7 +241,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
189 fps = VIDEO_TRANSCODING_FPS.AVERAGE 241 fps = VIDEO_TRANSCODING_FPS.AVERAGE
190 } 242 }
191 243
192 command = await presetH264(command, options.resolution, fps) 244 command = await presetH264(command, options.inputPath, options.resolution, fps)
193 245
194 if (options.resolution !== undefined) { 246 if (options.resolution !== undefined) {
195 // '?x720' or '720x?' for example 247 // '?x720' or '720x?' for example
@@ -208,7 +260,29 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
208 return command 260 return command
209} 261}
210 262
211async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 263async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
264 command = command.loop(undefined)
265
266 command = await presetH264VeryFast(command, options.audioPath, options.resolution)
267
268 command = command.input(options.audioPath)
269 .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
270 .outputOption('-tune stillimage')
271 .outputOption('-shortest')
272
273 return command
274}
275
276async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
277 command = await presetCopy(command)
278
279 command = command.outputOption('-map_metadata -1') // strip all metadata
280 .outputOption('-movflags faststart')
281
282 return command
283}
284
285async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
212 const videoPath = getHLSVideoPath(options) 286 const videoPath = getHLSVideoPath(options)
213 287
214 command = await presetCopy(command) 288 command = await presetCopy(command)
@@ -224,26 +298,26 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod
224 return command 298 return command
225} 299}
226 300
227function getHLSVideoPath (options: TranscodeOptions) { 301function getHLSVideoPath (options: HLSTranscodeOptions) {
228 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` 302 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
229} 303}
230 304
231async function onTranscodingSuccess (options: TranscodeOptions) { 305async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
232 if (!options.hlsPlaylist) return 306 if (options.type !== 'hls') return
233 307
234 // Fix wrong mapping with some ffmpeg versions
235 const fileContent = await readFile(options.outputPath) 308 const fileContent = await readFile(options.outputPath)
236 309
237 const videoFileName = options.hlsPlaylist.videoFilename 310 const videoFileName = options.hlsPlaylist.videoFilename
238 const videoFilePath = getHLSVideoPath(options) 311 const videoFilePath = getHLSVideoPath(options)
239 312
313 // Fix wrong mapping with some ffmpeg versions
240 const newContent = fileContent.toString() 314 const newContent = fileContent.toString()
241 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) 315 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
242 316
243 await writeFile(options.outputPath, newContent) 317 await writeFile(options.outputPath, newContent)
244} 318}
245 319
246function getVideoFileStream (path: string) { 320function getVideoStreamFromFile (path: string) {
247 return new Promise<any>((res, rej) => { 321 return new Promise<any>((res, rej) => {
248 ffmpeg.ffprobe(path, (err, metadata) => { 322 ffmpeg.ffprobe(path, (err, metadata) => {
249 if (err) return rej(err) 323 if (err) return rej(err)
@@ -263,44 +337,27 @@ function getVideoFileStream (path: string) {
263 * and quality. Superfast and ultrafast will give you better 337 * and quality. Superfast and ultrafast will give you better
264 * performance, but then quality is noticeably worse. 338 * performance, but then quality is noticeably worse.
265 */ 339 */
266async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { 340async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
267 let localCommand = await presetH264(command, resolution, fps) 341 let localCommand = await presetH264(command, input, resolution, fps)
342
268 localCommand = localCommand.outputOption('-preset:v veryfast') 343 localCommand = localCommand.outputOption('-preset:v veryfast')
269 .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) 344
270 /* 345 /*
271 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html 346 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
272 Our target situation is closer to a livestream than a stream, 347 Our target situation is closer to a livestream than a stream,
273 since we want to reduce as much a possible the encoding burden, 348 since we want to reduce as much a possible the encoding burden,
274 altough not to the point of a livestream where there is a hard 349 although not to the point of a livestream where there is a hard
275 constraint on the frames per second to be encoded. 350 constraint on the frames per second to be encoded.
276
277 why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
278 Make up for most of the loss of grain and macroblocking
279 with less computing power.
280 */ 351 */
281 352
282 return localCommand 353 return localCommand
283} 354}
284 355
285/** 356/**
286 * A preset optimised for a stillimage audio video
287 */
288async function presetStillImageWithAudio (
289 command: ffmpeg.FfmpegCommand,
290 resolution: VideoResolution,
291 fps: number
292): Promise<ffmpeg.FfmpegCommand> {
293 let localCommand = await presetH264VeryFast(command, resolution, fps)
294 localCommand = localCommand.outputOption('-tune stillimage')
295
296 return localCommand
297}
298
299/**
300 * A toolbox to play with audio 357 * A toolbox to play with audio
301 */ 358 */
302namespace audio { 359namespace audio {
303 export const get = (option: ffmpeg.FfmpegCommand | string) => { 360 export const get = (option: string) => {
304 // without position, ffprobe considers the last input only 361 // without position, ffprobe considers the last input only
305 // we make it consider the first input only 362 // we make it consider the first input only
306 // if you pass a file path to pos, then ffprobe acts on that file directly 363 // if you pass a file path to pos, then ffprobe acts on that file directly
@@ -322,11 +379,7 @@ namespace audio {
322 return res({ absolutePath: data.format.filename }) 379 return res({ absolutePath: data.format.filename })
323 } 380 }
324 381
325 if (typeof option === 'string') { 382 return ffmpeg.ffprobe(option, parseFfprobe)
326 return ffmpeg.ffprobe(option, parseFfprobe)
327 }
328
329 return option.ffprobe(parseFfprobe)
330 }) 383 })
331 } 384 }
332 385
@@ -368,7 +421,7 @@ namespace audio {
368 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel 421 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
369 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr 422 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
370 */ 423 */
371async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { 424async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
372 let localCommand = command 425 let localCommand = command
373 .format('mp4') 426 .format('mp4')
374 .videoCodec('libx264') 427 .videoCodec('libx264')
@@ -379,7 +432,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
379 .outputOption('-map_metadata -1') // strip all metadata 432 .outputOption('-map_metadata -1') // strip all metadata
380 .outputOption('-movflags faststart') 433 .outputOption('-movflags faststart')
381 434
382 const parsedAudio = await audio.get(localCommand) 435 const parsedAudio = await audio.get(input)
383 436
384 if (!parsedAudio.audioStream) { 437 if (!parsedAudio.audioStream) {
385 localCommand = localCommand.noAudio() 438 localCommand = localCommand.noAudio()
@@ -388,28 +441,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
388 .audioCodec('libfdk_aac') 441 .audioCodec('libfdk_aac')
389 .audioQuality(5) 442 .audioQuality(5)
390 } else { 443 } else {
391 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates 444 // we try to reduce the ceiling bitrate by making rough matches of bitrates
392 // of course this is far from perfect, but it might save some space in the end 445 // of course this is far from perfect, but it might save some space in the end
446 localCommand = localCommand.audioCodec('aac')
447
393 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ] 448 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
394 let bitrate: number
395 if (audio.bitrate[ audioCodecName ]) {
396 localCommand = localCommand.audioCodec('aac')
397 449
398 bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) 450 if (audio.bitrate[ audioCodecName ]) {
451 const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
399 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) 452 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
400 } 453 }
401 } 454 }
402 455
403 // Constrained Encoding (VBV) 456 if (fps) {
404 // https://slhck.info/video/2017/03/01/rate-control.html 457 // Constrained Encoding (VBV)
405 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate 458 // https://slhck.info/video/2017/03/01/rate-control.html
406 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) 459 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
407 localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) 460 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
408 461 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
409 // Keyframe interval of 2 seconds for faster seeking and resolution switching. 462
410 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html 463 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
411 // https://superuser.com/a/908325 464 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
412 localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) 465 // https://superuser.com/a/908325
466 localCommand = localCommand.outputOption(`-g ${fps * 2}`)
467 }
413 468
414 return localCommand 469 return localCommand
415} 470}