diff options
Diffstat (limited to 'server/helpers/ffmpeg-utils.ts')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 166 |
1 files changed, 102 insertions, 64 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 22bc25476..a108d46a0 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import * as ffmpeg from 'fluent-ffmpeg' | 1 | import * as ffmpeg from 'fluent-ffmpeg' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import { VideoResolution } from '../../shared/models/videos' | 3 | import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' |
4 | import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers' | 4 | import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers' |
5 | import { processImage } from './image-utils' | 5 | import { processImage } from './image-utils' |
6 | import { logger } from './logger' | 6 | import { logger } from './logger' |
@@ -55,6 +55,16 @@ async function getVideoFileFPS (path: string) { | |||
55 | return 0 | 55 | return 0 |
56 | } | 56 | } |
57 | 57 | ||
58 | async function getVideoFileBitrate (path: string) { | ||
59 | return new Promise<number>((res, rej) => { | ||
60 | ffmpeg.ffprobe(path, (err, metadata) => { | ||
61 | if (err) return rej(err) | ||
62 | |||
63 | return res(metadata.format.bit_rate) | ||
64 | }) | ||
65 | }) | ||
66 | } | ||
67 | |||
58 | function getDurationFromVideoFile (path: string) { | 68 | function getDurationFromVideoFile (path: string) { |
59 | return new Promise<number>((res, rej) => { | 69 | return new Promise<number>((res, rej) => { |
60 | ffmpeg.ffprobe(path, (err, metadata) => { | 70 | ffmpeg.ffprobe(path, (err, metadata) => { |
@@ -106,45 +116,50 @@ type TranscodeOptions = { | |||
106 | 116 | ||
107 | function transcode (options: TranscodeOptions) { | 117 | function transcode (options: TranscodeOptions) { |
108 | return new Promise<void>(async (res, rej) => { | 118 | return new Promise<void>(async (res, rej) => { |
109 | let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) | 119 | try { |
110 | .output(options.outputPath) | 120 | 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 | 121 | // On small/medium resolutions, limit FPS |
125 | if ( | 122 | if ( |
123 | options.resolution !== undefined && | ||
126 | options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | 124 | options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && |
127 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | 125 | fps > VIDEO_TRANSCODING_FPS.AVERAGE |
128 | ) { | 126 | ) { |
129 | fps = VIDEO_TRANSCODING_FPS.AVERAGE | 127 | fps = VIDEO_TRANSCODING_FPS.AVERAGE |
130 | } | 128 | } |
131 | } | ||
132 | 129 | ||
133 | if (fps) { | 130 | let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) |
134 | // Hard FPS limits | 131 | .output(options.outputPath) |
135 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX | 132 | command = await presetH264(command, options.resolution, fps) |
136 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN | ||
137 | 133 | ||
138 | command = command.withFPS(fps) | 134 | if (CONFIG.TRANSCODING.THREADS > 0) { |
139 | } | 135 | // if we don't set any threads ffmpeg will chose automatically |
136 | command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) | ||
137 | } | ||
138 | |||
139 | if (options.resolution !== undefined) { | ||
140 | // '?x720' or '720x?' for example | ||
141 | const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` | ||
142 | command = command.size(size) | ||
143 | } | ||
144 | |||
145 | if (fps) { | ||
146 | // Hard FPS limits | ||
147 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX | ||
148 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN | ||
140 | 149 | ||
141 | command | 150 | command = command.withFPS(fps) |
142 | .on('error', (err, stdout, stderr) => { | 151 | } |
143 | logger.error('Error in transcoding job.', { stdout, stderr }) | 152 | |
144 | return rej(err) | 153 | command |
145 | }) | 154 | .on('error', (err, stdout, stderr) => { |
146 | .on('end', res) | 155 | logger.error('Error in transcoding job.', { stdout, stderr }) |
147 | .run() | 156 | return rej(err) |
157 | }) | ||
158 | .on('end', res) | ||
159 | .run() | ||
160 | } catch (err) { | ||
161 | return rej(err) | ||
162 | } | ||
148 | }) | 163 | }) |
149 | } | 164 | } |
150 | 165 | ||
@@ -157,7 +172,8 @@ export { | |||
157 | transcode, | 172 | transcode, |
158 | getVideoFileFPS, | 173 | getVideoFileFPS, |
159 | computeResolutionsToTranscode, | 174 | computeResolutionsToTranscode, |
160 | audio | 175 | audio, |
176 | getVideoFileBitrate | ||
161 | } | 177 | } |
162 | 178 | ||
163 | // --------------------------------------------------------------------------- | 179 | // --------------------------------------------------------------------------- |
@@ -182,11 +198,10 @@ function getVideoFileStream (path: string) { | |||
182 | * and quality. Superfast and ultrafast will give you better | 198 | * and quality. Superfast and ultrafast will give you better |
183 | * performance, but then quality is noticeably worse. | 199 | * performance, but then quality is noticeably worse. |
184 | */ | 200 | */ |
185 | function veryfast (_ffmpeg) { | 201 | async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { |
186 | _ffmpeg | 202 | let localCommand = await presetH264(command, resolution, fps) |
187 | .preset(standard) | 203 | localCommand = localCommand.outputOption('-preset:v veryfast') |
188 | .outputOption('-preset:v veryfast') | 204 | .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) |
189 | .outputOption(['--aq-mode=2', '--aq-strength=1.3']) | ||
190 | /* | 205 | /* |
191 | MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html | 206 | MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html |
192 | Our target situation is closer to a livestream than a stream, | 207 | Our target situation is closer to a livestream than a stream, |
@@ -198,31 +213,39 @@ function veryfast (_ffmpeg) { | |||
198 | Make up for most of the loss of grain and macroblocking | 213 | Make up for most of the loss of grain and macroblocking |
199 | with less computing power. | 214 | with less computing power. |
200 | */ | 215 | */ |
216 | |||
217 | return localCommand | ||
201 | } | 218 | } |
202 | 219 | ||
203 | /** | 220 | /** |
204 | * A preset optimised for a stillimage audio video | 221 | * A preset optimised for a stillimage audio video |
205 | */ | 222 | */ |
206 | function audio (_ffmpeg) { | 223 | async function presetStillImageWithAudio ( |
207 | _ffmpeg | 224 | command: ffmpeg.FfmpegCommand, |
208 | .preset(veryfast) | 225 | resolution: VideoResolution, |
209 | .outputOption('-tune stillimage') | 226 | fps: number |
227 | ): Promise<ffmpeg.FfmpegCommand> { | ||
228 | let localCommand = await presetH264VeryFast(command, resolution, fps) | ||
229 | localCommand = localCommand.outputOption('-tune stillimage') | ||
230 | |||
231 | return localCommand | ||
210 | } | 232 | } |
211 | 233 | ||
212 | /** | 234 | /** |
213 | * A toolbox to play with audio | 235 | * A toolbox to play with audio |
214 | */ | 236 | */ |
215 | namespace audio { | 237 | namespace audio { |
216 | export const get = (_ffmpeg, pos: number | string = 0) => { | 238 | export const get = (option: ffmpeg.FfmpegCommand | string) => { |
217 | // without position, ffprobe considers the last input only | 239 | // without position, ffprobe considers the last input only |
218 | // we make it consider the first input only | 240 | // we make it consider the first input only |
219 | // if you pass a file path to pos, then ffprobe acts on that file directly | 241 | // 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) => { | 242 | return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => { |
221 | _ffmpeg.ffprobe(pos, (err,data) => { | 243 | |
244 | function parseFfprobe (err: any, data: ffmpeg.FfprobeData) { | ||
222 | if (err) return rej(err) | 245 | if (err) return rej(err) |
223 | 246 | ||
224 | if ('streams' in data) { | 247 | if ('streams' in data) { |
225 | const audioStream = data['streams'].find(stream => stream['codec_type'] === 'audio') | 248 | const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') |
226 | if (audioStream) { | 249 | if (audioStream) { |
227 | return res({ | 250 | return res({ |
228 | absolutePath: data.format.filename, | 251 | absolutePath: data.format.filename, |
@@ -230,8 +253,15 @@ namespace audio { | |||
230 | }) | 253 | }) |
231 | } | 254 | } |
232 | } | 255 | } |
256 | |||
233 | return res({ absolutePath: data.format.filename }) | 257 | return res({ absolutePath: data.format.filename }) |
234 | }) | 258 | } |
259 | |||
260 | if (typeof option === 'string') { | ||
261 | return ffmpeg.ffprobe(option, parseFfprobe) | ||
262 | } | ||
263 | |||
264 | return option.ffprobe(parseFfprobe) | ||
235 | }) | 265 | }) |
236 | } | 266 | } |
237 | 267 | ||
@@ -273,8 +303,8 @@ namespace audio { | |||
273 | * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel | 303 | * 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 | 304 | * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr |
275 | */ | 305 | */ |
276 | async function standard (_ffmpeg) { | 306 | async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { |
277 | let localFfmpeg = _ffmpeg | 307 | let localCommand = command |
278 | .format('mp4') | 308 | .format('mp4') |
279 | .videoCodec('libx264') | 309 | .videoCodec('libx264') |
280 | .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution | 310 | .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution |
@@ -282,30 +312,38 @@ async function standard (_ffmpeg) { | |||
282 | .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 | 312 | .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 |
283 | .outputOption('-map_metadata -1') // strip all metadata | 313 | .outputOption('-map_metadata -1') // strip all metadata |
284 | .outputOption('-movflags faststart') | 314 | .outputOption('-movflags faststart') |
285 | const _audio = await audio.get(localFfmpeg) | ||
286 | 315 | ||
287 | if (!_audio.audioStream) { | 316 | const parsedAudio = await audio.get(localCommand) |
288 | return localFfmpeg.noAudio() | ||
289 | } | ||
290 | 317 | ||
291 | // we favor VBR, if a good AAC encoder is available | 318 | if (!parsedAudio.audioStream) { |
292 | if ((await checkFFmpegEncoders()).get('libfdk_aac')) { | 319 | localCommand = localCommand.noAudio() |
293 | return localFfmpeg | 320 | } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available |
321 | localCommand = localCommand | ||
294 | .audioCodec('libfdk_aac') | 322 | .audioCodec('libfdk_aac') |
295 | .audioQuality(5) | 323 | .audioQuality(5) |
324 | } else { | ||
325 | // we try to reduce the ceiling bitrate by making rough correspondances of bitrates | ||
326 | // of course this is far from perfect, but it might save some space in the end | ||
327 | const audioCodecName = parsedAudio.audioStream[ 'codec_name' ] | ||
328 | let bitrate: number | ||
329 | if (audio.bitrate[ audioCodecName ]) { | ||
330 | bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) | ||
331 | |||
332 | if (bitrate === -1) localCommand = localCommand.audioCodec('copy') | ||
333 | else if (bitrate !== undefined) localCommand = localCommand.audioBitrate(bitrate) | ||
334 | } | ||
296 | } | 335 | } |
297 | 336 | ||
298 | // we try to reduce the ceiling bitrate by making rough correspondances of bitrates | 337 | // Constrained Encoding (VBV) |
299 | // of course this is far from perfect, but it might save some space in the end | 338 | // https://slhck.info/video/2017/03/01/rate-control.html |
300 | const audioCodecName = _audio.audioStream['codec_name'] | 339 | // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate |
301 | let bitrate: number | 340 | const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) |
302 | if (audio.bitrate[audioCodecName]) { | 341 | 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 | 342 | ||
308 | if (bitrate !== undefined) return localFfmpeg.audioBitrate(bitrate) | 343 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. |
344 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | ||
345 | // https://superuser.com/a/908325 | ||
346 | localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) | ||
309 | 347 | ||
310 | return localFfmpeg | 348 | return localCommand |
311 | } | 349 | } |