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.ts209
1 files changed, 137 insertions, 72 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 22bc25476..133b1b03b 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,7 +1,7 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { join } from 'path' 2import { dirname, join } from 'path'
3import { VideoResolution } from '../../shared/models/videos' 3import { getTargetBitrate, VideoResolution } from '../../shared/models/videos'
4import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers' 4import { CONFIG, 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'
7import { checkFFmpegEncoders } from '../initializers/checker-before-init' 7import { checkFFmpegEncoders } from '../initializers/checker-before-init'
@@ -29,19 +29,28 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
29 return resolutionsEnabled 29 return resolutionsEnabled
30} 30}
31 31
32async function getVideoFileResolution (path: string) { 32async function getVideoFileSize (path: string) {
33 const videoStream = await getVideoFileStream(path) 33 const videoStream = await getVideoFileStream(path)
34 34
35 return { 35 return {
36 videoFileResolution: Math.min(videoStream.height, videoStream.width), 36 width: videoStream.width,
37 isPortraitMode: videoStream.height > videoStream.width 37 height: videoStream.height
38 }
39}
40
41async function getVideoFileResolution (path: string) {
42 const size = await getVideoFileSize(path)
43
44 return {
45 videoFileResolution: Math.min(size.height, size.width),
46 isPortraitMode: size.height > size.width
38 } 47 }
39} 48}
40 49
41async function getVideoFileFPS (path: string) { 50async function getVideoFileFPS (path: string) {
42 const videoStream = await getVideoFileStream(path) 51 const videoStream = await getVideoFileStream(path)
43 52
44 for (const key of [ 'r_frame_rate' , 'avg_frame_rate' ]) { 53 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
45 const valuesText: string = videoStream[key] 54 const valuesText: string = videoStream[key]
46 if (!valuesText) continue 55 if (!valuesText) continue
47 56
@@ -55,6 +64,16 @@ async function getVideoFileFPS (path: string) {
55 return 0 64 return 0
56} 65}
57 66
67async function getVideoFileBitrate (path: string) {
68 return new Promise<number>((res, rej) => {
69 ffmpeg.ffprobe(path, (err, metadata) => {
70 if (err) return rej(err)
71
72 return res(metadata.format.bit_rate)
73 })
74 })
75}
76
58function getDurationFromVideoFile (path: string) { 77function getDurationFromVideoFile (path: string) {
59 return new Promise<number>((res, rej) => { 78 return new Promise<number>((res, rej) => {
60 ffmpeg.ffprobe(path, (err, metadata) => { 79 ffmpeg.ffprobe(path, (err, metadata) => {
@@ -100,64 +119,87 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
100type TranscodeOptions = { 119type TranscodeOptions = {
101 inputPath: string 120 inputPath: string
102 outputPath: string 121 outputPath: string
103 resolution?: VideoResolution 122 resolution: VideoResolution
104 isPortraitMode?: boolean 123 isPortraitMode?: boolean
124
125 hlsPlaylist?: {
126 videoFilename: string
127 }
105} 128}
106 129
107function transcode (options: TranscodeOptions) { 130function transcode (options: TranscodeOptions) {
108 return new Promise<void>(async (res, rej) => { 131 return new Promise<void>(async (res, rej) => {
109 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) 132 try {
110 .output(options.outputPath) 133 let fps = await getVideoFileFPS(options.inputPath)
111 .preset(standard)
112
113 if (CONFIG.TRANSCODING.THREADS > 0) {
114 // if we don't set any threads ffmpeg will chose automatically
115 command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
116 }
117
118 let fps = await getVideoFileFPS(options.inputPath)
119 if (options.resolution !== undefined) {
120 // '?x720' or '720x?' for example
121 const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
122 command = command.size(size)
123
124 // On small/medium resolutions, limit FPS 134 // On small/medium resolutions, limit FPS
125 if ( 135 if (
136 options.resolution !== undefined &&
126 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && 137 options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN &&
127 fps > VIDEO_TRANSCODING_FPS.AVERAGE 138 fps > VIDEO_TRANSCODING_FPS.AVERAGE
128 ) { 139 ) {
129 fps = VIDEO_TRANSCODING_FPS.AVERAGE 140 fps = VIDEO_TRANSCODING_FPS.AVERAGE
130 } 141 }
131 }
132 142
133 if (fps) { 143 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
134 // Hard FPS limits 144 .output(options.outputPath)
135 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX 145 command = await presetH264(command, options.resolution, fps)
136 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
137 146
138 command = command.withFPS(fps) 147 if (CONFIG.TRANSCODING.THREADS > 0) {
139 } 148 // if we don't set any threads ffmpeg will chose automatically
149 command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
150 }
151
152 if (options.resolution !== undefined) {
153 // '?x720' or '720x?' for example
154 const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}`
155 command = command.size(size)
156 }
157
158 if (fps) {
159 // Hard FPS limits
160 if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX
161 else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN
162
163 command = command.withFPS(fps)
164 }
165
166 if (options.hlsPlaylist) {
167 const videoPath = `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
168
169 command = command.outputOption('-hls_time 4')
170 .outputOption('-hls_list_size 0')
171 .outputOption('-hls_playlist_type vod')
172 .outputOption('-hls_segment_filename ' + videoPath)
173 .outputOption('-hls_segment_type fmp4')
174 .outputOption('-f hls')
175 .outputOption('-hls_flags single_file')
176 }
140 177
141 command 178 command
142 .on('error', (err, stdout, stderr) => { 179 .on('error', (err, stdout, stderr) => {
143 logger.error('Error in transcoding job.', { stdout, stderr }) 180 logger.error('Error in transcoding job.', { stdout, stderr })
144 return rej(err) 181 return rej(err)
145 }) 182 })
146 .on('end', res) 183 .on('end', res)
147 .run() 184 .run()
185 } catch (err) {
186 return rej(err)
187 }
148 }) 188 })
149} 189}
150 190
151// --------------------------------------------------------------------------- 191// ---------------------------------------------------------------------------
152 192
153export { 193export {
194 getVideoFileSize,
154 getVideoFileResolution, 195 getVideoFileResolution,
155 getDurationFromVideoFile, 196 getDurationFromVideoFile,
156 generateImageFromVideoFile, 197 generateImageFromVideoFile,
157 transcode, 198 transcode,
158 getVideoFileFPS, 199 getVideoFileFPS,
159 computeResolutionsToTranscode, 200 computeResolutionsToTranscode,
160 audio 201 audio,
202 getVideoFileBitrate
161} 203}
162 204
163// --------------------------------------------------------------------------- 205// ---------------------------------------------------------------------------
@@ -168,7 +210,7 @@ function getVideoFileStream (path: string) {
168 if (err) return rej(err) 210 if (err) return rej(err)
169 211
170 const videoStream = metadata.streams.find(s => s.codec_type === 'video') 212 const videoStream = metadata.streams.find(s => s.codec_type === 'video')
171 if (!videoStream) throw new Error('Cannot find video stream of ' + path) 213 if (!videoStream) return rej(new Error('Cannot find video stream of ' + path))
172 214
173 return res(videoStream) 215 return res(videoStream)
174 }) 216 })
@@ -182,11 +224,10 @@ function getVideoFileStream (path: string) {
182 * and quality. Superfast and ultrafast will give you better 224 * and quality. Superfast and ultrafast will give you better
183 * performance, but then quality is noticeably worse. 225 * performance, but then quality is noticeably worse.
184 */ 226 */
185function veryfast (_ffmpeg) { 227async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
186 _ffmpeg 228 let localCommand = await presetH264(command, resolution, fps)
187 .preset(standard) 229 localCommand = localCommand.outputOption('-preset:v veryfast')
188 .outputOption('-preset:v veryfast') 230 .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ])
189 .outputOption(['--aq-mode=2', '--aq-strength=1.3'])
190 /* 231 /*
191 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html 232 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
192 Our target situation is closer to a livestream than a stream, 233 Our target situation is closer to a livestream than a stream,
@@ -198,31 +239,39 @@ function veryfast (_ffmpeg) {
198 Make up for most of the loss of grain and macroblocking 239 Make up for most of the loss of grain and macroblocking
199 with less computing power. 240 with less computing power.
200 */ 241 */
242
243 return localCommand
201} 244}
202 245
203/** 246/**
204 * A preset optimised for a stillimage audio video 247 * A preset optimised for a stillimage audio video
205 */ 248 */
206function audio (_ffmpeg) { 249async function presetStillImageWithAudio (
207 _ffmpeg 250 command: ffmpeg.FfmpegCommand,
208 .preset(veryfast) 251 resolution: VideoResolution,
209 .outputOption('-tune stillimage') 252 fps: number
253): Promise<ffmpeg.FfmpegCommand> {
254 let localCommand = await presetH264VeryFast(command, resolution, fps)
255 localCommand = localCommand.outputOption('-tune stillimage')
256
257 return localCommand
210} 258}
211 259
212/** 260/**
213 * A toolbox to play with audio 261 * A toolbox to play with audio
214 */ 262 */
215namespace audio { 263namespace audio {
216 export const get = (_ffmpeg, pos: number | string = 0) => { 264 export const get = (option: ffmpeg.FfmpegCommand | string) => {
217 // without position, ffprobe considers the last input only 265 // without position, ffprobe considers the last input only
218 // we make it consider the first input only 266 // we make it consider the first input only
219 // if you pass a file path to pos, then ffprobe acts on that file directly 267 // if you pass a file path to pos, then ffprobe acts on that file directly
220 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => { 268 return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => {
221 _ffmpeg.ffprobe(pos, (err,data) => { 269
270 function parseFfprobe (err: any, data: ffmpeg.FfprobeData) {
222 if (err) return rej(err) 271 if (err) return rej(err)
223 272
224 if ('streams' in data) { 273 if ('streams' in data) {
225 const audioStream = data['streams'].find(stream => stream['codec_type'] === 'audio') 274 const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
226 if (audioStream) { 275 if (audioStream) {
227 return res({ 276 return res({
228 absolutePath: data.format.filename, 277 absolutePath: data.format.filename,
@@ -230,8 +279,15 @@ namespace audio {
230 }) 279 })
231 } 280 }
232 } 281 }
282
233 return res({ absolutePath: data.format.filename }) 283 return res({ absolutePath: data.format.filename })
234 }) 284 }
285
286 if (typeof option === 'string') {
287 return ffmpeg.ffprobe(option, parseFfprobe)
288 }
289
290 return option.ffprobe(parseFfprobe)
235 }) 291 })
236 } 292 }
237 293
@@ -273,39 +329,48 @@ namespace audio {
273 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel 329 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
274 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr 330 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
275 */ 331 */
276async function standard (_ffmpeg) { 332async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
277 let localFfmpeg = _ffmpeg 333 let localCommand = command
278 .format('mp4') 334 .format('mp4')
279 .videoCodec('libx264') 335 .videoCodec('libx264')
280 .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution 336 .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution
281 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it 337 .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it
282 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 338 .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
339 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
283 .outputOption('-map_metadata -1') // strip all metadata 340 .outputOption('-map_metadata -1') // strip all metadata
284 .outputOption('-movflags faststart') 341 .outputOption('-movflags faststart')
285 const _audio = await audio.get(localFfmpeg)
286 342
287 if (!_audio.audioStream) { 343 const parsedAudio = await audio.get(localCommand)
288 return localFfmpeg.noAudio()
289 }
290 344
291 // we favor VBR, if a good AAC encoder is available 345 if (!parsedAudio.audioStream) {
292 if ((await checkFFmpegEncoders()).get('libfdk_aac')) { 346 localCommand = localCommand.noAudio()
293 return localFfmpeg 347 } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available
348 localCommand = localCommand
294 .audioCodec('libfdk_aac') 349 .audioCodec('libfdk_aac')
295 .audioQuality(5) 350 .audioQuality(5)
351 } else {
352 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates
353 // of course this is far from perfect, but it might save some space in the end
354 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
355 let bitrate: number
356 if (audio.bitrate[ audioCodecName ]) {
357 localCommand = localCommand.audioCodec('aac')
358
359 bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
360 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
361 }
296 } 362 }
297 363
298 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates 364 // Constrained Encoding (VBV)
299 // of course this is far from perfect, but it might save some space in the end 365 // https://slhck.info/video/2017/03/01/rate-control.html
300 const audioCodecName = _audio.audioStream['codec_name'] 366 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
301 let bitrate: number 367 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
302 if (audio.bitrate[audioCodecName]) { 368 localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`])
303 bitrate = audio.bitrate[audioCodecName](_audio.audioStream['bit_rate'])
304
305 if (bitrate === -1) return localFfmpeg.audioCodec('copy')
306 }
307 369
308 if (bitrate !== undefined) return localFfmpeg.audioBitrate(bitrate) 370 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
371 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
372 // https://superuser.com/a/908325
373 localCommand = localCommand.outputOption(`-g ${ fps * 2 }`)
309 374
310 return localFfmpeg 375 return localCommand
311} 376}