diff options
Diffstat (limited to 'server')
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 159 | ||||
-rw-r--r-- | server/initializers/checker-before-init.ts | 8 | ||||
-rw-r--r-- | server/tests/api/videos/multiple-servers.ts | 8 | ||||
-rw-r--r-- | server/tests/api/videos/video-transcoder.ts | 8 |
4 files changed, 99 insertions, 84 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 037bf703a..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, getTargetBitrate } 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' |
@@ -116,46 +116,50 @@ type TranscodeOptions = { | |||
116 | 116 | ||
117 | function transcode (options: TranscodeOptions) { | 117 | function transcode (options: TranscodeOptions) { |
118 | return new Promise<void>(async (res, rej) => { | 118 | return new Promise<void>(async (res, rej) => { |
119 | let fps = await getVideoFileFPS(options.inputPath) | 119 | try { |
120 | // On small/medium resolutions, limit FPS | 120 | let fps = await getVideoFileFPS(options.inputPath) |
121 | if ( | 121 | // On small/medium resolutions, limit FPS |
122 | options.resolution !== undefined && | 122 | if ( |
123 | options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | 123 | options.resolution !== undefined && |
124 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | 124 | options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && |
125 | ) { | 125 | fps > VIDEO_TRANSCODING_FPS.AVERAGE |
126 | fps = VIDEO_TRANSCODING_FPS.AVERAGE | 126 | ) { |
127 | } | 127 | fps = VIDEO_TRANSCODING_FPS.AVERAGE |
128 | } | ||
128 | 129 | ||
129 | let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) | 130 | let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) |
130 | .output(options.outputPath) | 131 | .output(options.outputPath) |
131 | command = await presetH264(command, options.resolution, fps) | 132 | command = await presetH264(command, options.resolution, fps) |
132 | 133 | ||
133 | if (CONFIG.TRANSCODING.THREADS > 0) { | 134 | if (CONFIG.TRANSCODING.THREADS > 0) { |
134 | // if we don't set any threads ffmpeg will chose automatically | 135 | // if we don't set any threads ffmpeg will chose automatically |
135 | command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) | 136 | command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) |
136 | } | 137 | } |
137 | 138 | ||
138 | if (options.resolution !== undefined) { | 139 | if (options.resolution !== undefined) { |
139 | // '?x720' or '720x?' for example | 140 | // '?x720' or '720x?' for example |
140 | const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` | 141 | const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` |
141 | command = command.size(size) | 142 | command = command.size(size) |
142 | } | 143 | } |
143 | 144 | ||
144 | if (fps) { | 145 | if (fps) { |
145 | // Hard FPS limits | 146 | // Hard FPS limits |
146 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX | 147 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = VIDEO_TRANSCODING_FPS.MAX |
147 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN | 148 | else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN |
148 | 149 | ||
149 | command = command.withFPS(fps) | 150 | command = command.withFPS(fps) |
150 | } | 151 | } |
151 | 152 | ||
152 | command | 153 | command |
153 | .on('error', (err, stdout, stderr) => { | 154 | .on('error', (err, stdout, stderr) => { |
154 | logger.error('Error in transcoding job.', { stdout, stderr }) | 155 | logger.error('Error in transcoding job.', { stdout, stderr }) |
155 | return rej(err) | 156 | return rej(err) |
156 | }) | 157 | }) |
157 | .on('end', res) | 158 | .on('end', res) |
158 | .run() | 159 | .run() |
160 | } catch (err) { | ||
161 | return rej(err) | ||
162 | } | ||
159 | }) | 163 | }) |
160 | } | 164 | } |
161 | 165 | ||
@@ -194,11 +198,10 @@ function getVideoFileStream (path: string) { | |||
194 | * and quality. Superfast and ultrafast will give you better | 198 | * and quality. Superfast and ultrafast will give you better |
195 | * performance, but then quality is noticeably worse. | 199 | * performance, but then quality is noticeably worse. |
196 | */ | 200 | */ |
197 | async function presetH264VeryFast (ffmpeg: ffmpeg, resolution: VideoResolution, fps: number): ffmpeg { | 201 | async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { |
198 | const localFfmpeg = await presetH264(ffmpeg, resolution, fps) | 202 | let localCommand = await presetH264(command, resolution, fps) |
199 | localFfmpeg | 203 | localCommand = localCommand.outputOption('-preset:v veryfast') |
200 | .outputOption('-preset:v veryfast') | 204 | .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) |
201 | .outputOption(['--aq-mode=2', '--aq-strength=1.3']) | ||
202 | /* | 205 | /* |
203 | 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 |
204 | Our target situation is closer to a livestream than a stream, | 207 | Our target situation is closer to a livestream than a stream, |
@@ -210,31 +213,39 @@ async function presetH264VeryFast (ffmpeg: ffmpeg, resolution: VideoResolution, | |||
210 | Make up for most of the loss of grain and macroblocking | 213 | Make up for most of the loss of grain and macroblocking |
211 | with less computing power. | 214 | with less computing power. |
212 | */ | 215 | */ |
216 | |||
217 | return localCommand | ||
213 | } | 218 | } |
214 | 219 | ||
215 | /** | 220 | /** |
216 | * A preset optimised for a stillimage audio video | 221 | * A preset optimised for a stillimage audio video |
217 | */ | 222 | */ |
218 | async function presetStillImageWithAudio (ffmpeg: ffmpeg, resolution: VideoResolution, fps: number): ffmpeg { | 223 | async function presetStillImageWithAudio ( |
219 | const localFfmpeg = await presetH264VeryFast(ffmpeg, resolution, fps) | 224 | command: ffmpeg.FfmpegCommand, |
220 | localFfmpeg | 225 | resolution: VideoResolution, |
221 | .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 | ||
222 | } | 232 | } |
223 | 233 | ||
224 | /** | 234 | /** |
225 | * A toolbox to play with audio | 235 | * A toolbox to play with audio |
226 | */ | 236 | */ |
227 | namespace audio { | 237 | namespace audio { |
228 | export const get = (_ffmpeg, pos: number | string = 0) => { | 238 | export const get = (option: ffmpeg.FfmpegCommand | string) => { |
229 | // without position, ffprobe considers the last input only | 239 | // without position, ffprobe considers the last input only |
230 | // we make it consider the first input only | 240 | // we make it consider the first input only |
231 | // 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 |
232 | return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => { | 242 | return new Promise<{ absolutePath: string, audioStream?: any }>((res, rej) => { |
233 | _ffmpeg.ffprobe(pos, (err,data) => { | 243 | |
244 | function parseFfprobe (err: any, data: ffmpeg.FfprobeData) { | ||
234 | if (err) return rej(err) | 245 | if (err) return rej(err) |
235 | 246 | ||
236 | if ('streams' in data) { | 247 | if ('streams' in data) { |
237 | const audioStream = data['streams'].find(stream => stream['codec_type'] === 'audio') | 248 | const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') |
238 | if (audioStream) { | 249 | if (audioStream) { |
239 | return res({ | 250 | return res({ |
240 | absolutePath: data.format.filename, | 251 | absolutePath: data.format.filename, |
@@ -242,8 +253,15 @@ namespace audio { | |||
242 | }) | 253 | }) |
243 | } | 254 | } |
244 | } | 255 | } |
256 | |||
245 | return res({ absolutePath: data.format.filename }) | 257 | return res({ absolutePath: data.format.filename }) |
246 | }) | 258 | } |
259 | |||
260 | if (typeof option === 'string') { | ||
261 | return ffmpeg.ffprobe(option, parseFfprobe) | ||
262 | } | ||
263 | |||
264 | return option.ffprobe(parseFfprobe) | ||
247 | }) | 265 | }) |
248 | } | 266 | } |
249 | 267 | ||
@@ -285,8 +303,8 @@ namespace audio { | |||
285 | * 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 |
286 | * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr | 304 | * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr |
287 | */ | 305 | */ |
288 | async function presetH264 (ffmpeg: ffmpeg, resolution: VideoResolution, fps: number): ffmpeg { | 306 | async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { |
289 | let localFfmpeg = ffmpeg | 307 | let localCommand = command |
290 | .format('mp4') | 308 | .format('mp4') |
291 | .videoCodec('libx264') | 309 | .videoCodec('libx264') |
292 | .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 |
@@ -294,41 +312,38 @@ async function presetH264 (ffmpeg: ffmpeg, resolution: VideoResolution, fps: num | |||
294 | .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 |
295 | .outputOption('-map_metadata -1') // strip all metadata | 313 | .outputOption('-map_metadata -1') // strip all metadata |
296 | .outputOption('-movflags faststart') | 314 | .outputOption('-movflags faststart') |
297 | const _audio = await audio.get(localFfmpeg) | ||
298 | 315 | ||
299 | if (!_audio.audioStream) { | 316 | const parsedAudio = await audio.get(localCommand) |
300 | return localFfmpeg.noAudio() | ||
301 | } | ||
302 | 317 | ||
303 | // we favor VBR, if a good AAC encoder is available | 318 | if (!parsedAudio.audioStream) { |
304 | if ((await checkFFmpegEncoders()).get('libfdk_aac')) { | 319 | localCommand = localCommand.noAudio() |
305 | return localFfmpeg | 320 | } else if ((await checkFFmpegEncoders()).get('libfdk_aac')) { // we favor VBR, if a good AAC encoder is available |
321 | localCommand = localCommand | ||
306 | .audioCodec('libfdk_aac') | 322 | .audioCodec('libfdk_aac') |
307 | .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 | } | ||
308 | } | 335 | } |
309 | 336 | ||
310 | // we try to reduce the ceiling bitrate by making rough correspondances of bitrates | ||
311 | // of course this is far from perfect, but it might save some space in the end | ||
312 | const audioCodecName = _audio.audioStream['codec_name'] | ||
313 | let bitrate: number | ||
314 | if (audio.bitrate[audioCodecName]) { | ||
315 | bitrate = audio.bitrate[audioCodecName](_audio.audioStream['bit_rate']) | ||
316 | |||
317 | if (bitrate === -1) return localFfmpeg.audioCodec('copy') | ||
318 | } | ||
319 | |||
320 | if (bitrate !== undefined) return localFfmpeg.audioBitrate(bitrate) | ||
321 | |||
322 | // Constrained Encoding (VBV) | 337 | // Constrained Encoding (VBV) |
323 | // https://slhck.info/video/2017/03/01/rate-control.html | 338 | // https://slhck.info/video/2017/03/01/rate-control.html |
324 | // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate | 339 | // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate |
325 | const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) | 340 | const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) |
326 | localFfmpeg = localFfmpeg.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) | 341 | localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) |
327 | 342 | ||
328 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | 343 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. |
329 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | 344 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html |
330 | // https://superuser.com/a/908325 | 345 | // https://superuser.com/a/908325 |
331 | localFfmpeg = localFfmpeg.outputOption(`-g ${ fps * 2 }`) | 346 | localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) |
332 | 347 | ||
333 | return localFfmpeg | 348 | return localCommand |
334 | } | 349 | } |
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 4f46d406a..9dfb5d68c 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts | |||
@@ -77,7 +77,7 @@ async function checkFFmpeg (CONFIG: { TRANSCODING: { ENABLED: boolean } }) { | |||
77 | } | 77 | } |
78 | } | 78 | } |
79 | 79 | ||
80 | checkFFmpegEncoders() | 80 | return checkFFmpegEncoders() |
81 | } | 81 | } |
82 | 82 | ||
83 | // Optional encoders, if present, can be used to improve transcoding | 83 | // Optional encoders, if present, can be used to improve transcoding |
@@ -95,10 +95,10 @@ async function checkFFmpegEncoders (): Promise<Map<string, boolean>> { | |||
95 | supportedOptionalEncoders = new Map<string, boolean>() | 95 | supportedOptionalEncoders = new Map<string, boolean>() |
96 | 96 | ||
97 | for (const encoder of optionalEncoders) { | 97 | for (const encoder of optionalEncoders) { |
98 | supportedOptionalEncoders.set(encoder, | 98 | supportedOptionalEncoders.set(encoder, encoders[encoder] !== undefined) |
99 | encoders[encoder] !== undefined | ||
100 | ) | ||
101 | } | 99 | } |
100 | |||
101 | return supportedOptionalEncoders | ||
102 | } | 102 | } |
103 | 103 | ||
104 | // --------------------------------------------------------------------------- | 104 | // --------------------------------------------------------------------------- |
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts index 4553ee855..b9ace2885 100644 --- a/server/tests/api/videos/multiple-servers.ts +++ b/server/tests/api/videos/multiple-servers.ts | |||
@@ -987,19 +987,19 @@ describe('Test multiple servers', function () { | |||
987 | files: [ | 987 | files: [ |
988 | { | 988 | { |
989 | resolution: 720, | 989 | resolution: 720, |
990 | size: 36000 | 990 | size: 72000 |
991 | }, | 991 | }, |
992 | { | 992 | { |
993 | resolution: 480, | 993 | resolution: 480, |
994 | size: 21000 | 994 | size: 45000 |
995 | }, | 995 | }, |
996 | { | 996 | { |
997 | resolution: 360, | 997 | resolution: 360, |
998 | size: 17000 | 998 | size: 34600 |
999 | }, | 999 | }, |
1000 | { | 1000 | { |
1001 | resolution: 240, | 1001 | resolution: 240, |
1002 | size: 13000 | 1002 | size: 24770 |
1003 | } | 1003 | } |
1004 | ] | 1004 | ] |
1005 | } | 1005 | } |
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index 0ce5197ea..0a567873c 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts | |||
@@ -123,7 +123,7 @@ describe('Test video transcoding', function () { | |||
123 | expect(videoDetails.files).to.have.lengthOf(4) | 123 | expect(videoDetails.files).to.have.lengthOf(4) |
124 | 124 | ||
125 | const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') | 125 | const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') |
126 | const probe = await audio.get(ffmpeg, path) | 126 | const probe = await audio.get(path) |
127 | 127 | ||
128 | if (probe.audioStream) { | 128 | if (probe.audioStream) { |
129 | expect(probe.audioStream[ 'codec_name' ]).to.be.equal('aac') | 129 | expect(probe.audioStream[ 'codec_name' ]).to.be.equal('aac') |
@@ -154,7 +154,7 @@ describe('Test video transcoding', function () { | |||
154 | 154 | ||
155 | expect(videoDetails.files).to.have.lengthOf(4) | 155 | expect(videoDetails.files).to.have.lengthOf(4) |
156 | const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') | 156 | const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') |
157 | const probe = await audio.get(ffmpeg, path) | 157 | const probe = await audio.get(path) |
158 | expect(probe).to.not.have.property('audioStream') | 158 | expect(probe).to.not.have.property('audioStream') |
159 | } | 159 | } |
160 | }) | 160 | }) |
@@ -179,9 +179,9 @@ describe('Test video transcoding', function () { | |||
179 | 179 | ||
180 | expect(videoDetails.files).to.have.lengthOf(4) | 180 | expect(videoDetails.files).to.have.lengthOf(4) |
181 | const fixturePath = buildAbsoluteFixturePath(videoAttributes.fixture) | 181 | const fixturePath = buildAbsoluteFixturePath(videoAttributes.fixture) |
182 | const fixtureVideoProbe = await audio.get(ffmpeg, fixturePath) | 182 | const fixtureVideoProbe = await audio.get(fixturePath) |
183 | const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') | 183 | const path = join(root(), 'test2', 'videos', video.uuid + '-240.mp4') |
184 | const videoProbe = await audio.get(ffmpeg, path) | 184 | const videoProbe = await audio.get(path) |
185 | if (videoProbe.audioStream && fixtureVideoProbe.audioStream) { | 185 | if (videoProbe.audioStream && fixtureVideoProbe.audioStream) { |
186 | const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ] | 186 | const toOmit = [ 'max_bit_rate', 'duration', 'duration_ts', 'nb_frames', 'start_time', 'start_pts' ] |
187 | expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit)) | 187 | expect(omit(videoProbe.audioStream, toOmit)).to.be.deep.equal(omit(fixtureVideoProbe.audioStream, toOmit)) |