diff options
Diffstat (limited to 'server/helpers/ffmpeg-utils.ts')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 209 |
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 @@ | |||
1 | import * as ffmpeg from 'fluent-ffmpeg' | 1 | import * as ffmpeg from 'fluent-ffmpeg' |
2 | import { join } from 'path' | 2 | import { dirname, 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/constants' |
5 | import { processImage } from './image-utils' | 5 | import { processImage } from './image-utils' |
6 | import { logger } from './logger' | 6 | import { logger } from './logger' |
7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' | 7 | import { checkFFmpegEncoders } from '../initializers/checker-before-init' |
@@ -29,19 +29,28 @@ function computeResolutionsToTranscode (videoFileHeight: number) { | |||
29 | return resolutionsEnabled | 29 | return resolutionsEnabled |
30 | } | 30 | } |
31 | 31 | ||
32 | async function getVideoFileResolution (path: string) { | 32 | async 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 | |||
41 | async 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 | ||
41 | async function getVideoFileFPS (path: string) { | 50 | async 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 | ||
67 | async 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 | |||
58 | function getDurationFromVideoFile (path: string) { | 77 | function 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 | |||
100 | type TranscodeOptions = { | 119 | type 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 | ||
107 | function transcode (options: TranscodeOptions) { | 130 | function 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 | ||
153 | export { | 193 | export { |
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 | */ |
185 | function veryfast (_ffmpeg) { | 227 | async 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 | */ |
206 | function audio (_ffmpeg) { | 249 | async 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 | */ |
215 | namespace audio { | 263 | namespace 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 | */ |
276 | async function standard (_ffmpeg) { | 332 | async 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 | } |