X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fhelpers%2Fffmpeg-utils.ts;h=00c32e99a7f2d20d6a86ced5f288105a763df527;hb=d8232bac6abfb70953229a4164395f05cec90b35;hp=2fdf34cb7d6918245a95dc9752dea4c5244ce3a8;hpb=1600235a2f4e30c5d4e7d4342d1c299845decc60;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 2fdf34cb7..00c32e99a 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -14,11 +14,13 @@ function computeResolutionsToTranscode (videoFileHeight: number) { // Put in the order we want to proceed jobs const resolutions = [ + VideoResolution.H_NOVIDEO, VideoResolution.H_480P, VideoResolution.H_360P, VideoResolution.H_720P, VideoResolution.H_240P, - VideoResolution.H_1080P + VideoResolution.H_1080P, + VideoResolution.H_4K ] for (const resolution of resolutions) { @@ -30,17 +32,53 @@ function computeResolutionsToTranscode (videoFileHeight: number) { return resolutionsEnabled } -async function getVideoFileSize (path: string) { +async function getVideoStreamSize (path: string) { const videoStream = await getVideoStreamFromFile(path) - return { - width: videoStream.width, - height: videoStream.height + return videoStream === null + ? { width: 0, height: 0 } + : { width: videoStream.width, height: videoStream.height } +} + +async function getVideoStreamCodec (path: string) { + const videoStream = await getVideoStreamFromFile(path) + + if (!videoStream) return '' + + const videoCodec = videoStream.codec_tag_string + + const baseProfileMatrix = { + 'High': '6400', + 'Main': '4D40', + 'Baseline': '42E0' } + + let baseProfile = baseProfileMatrix[videoStream.profile] + if (!baseProfile) { + logger.warn('Cannot get video profile codec of %s.', path, { videoStream }) + baseProfile = baseProfileMatrix['High'] // Fallback + } + + const level = videoStream.level.toString(16) + + return `${videoCodec}.${baseProfile}${level}` +} + +async function getAudioStreamCodec (path: string) { + const { audioStream } = await audio.get(path) + + if (!audioStream) return '' + + const audioCodec = audioStream.codec_name + if (audioCodec === 'aac') return 'mp4a.40.2' + + logger.warn('Cannot get audio codec of %s.', path, { audioStream }) + + return 'mp4a.40.2' // Fallback } async function getVideoFileResolution (path: string) { - const size = await getVideoFileSize(path) + const size = await getVideoStreamSize(path) return { videoFileResolution: Math.min(size.height, size.width), @@ -50,9 +88,10 @@ async function getVideoFileResolution (path: string) { async function getVideoFileFPS (path: string) { const videoStream = await getVideoStreamFromFile(path) + if (videoStream === null) return 0 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { - const valuesText: string = videoStream[key] + const valuesText: string = videoStream[ key ] if (!valuesText) continue const [ frames, seconds ] = valuesText.split('/') @@ -117,37 +156,61 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima } } -type TranscodeOptions = { +type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' + +interface BaseTranscodeOptions { + type: TranscodeOptionsType inputPath: string outputPath: string resolution: VideoResolution isPortraitMode?: boolean - doQuickTranscode?: Boolean +} - hlsPlaylist?: { +interface HLSTranscodeOptions extends BaseTranscodeOptions { + type: 'hls' + copyCodecs: boolean + hlsPlaylist: { videoFilename: string } } +interface QuickTranscodeOptions extends BaseTranscodeOptions { + type: 'quick-transcode' +} + +interface VideoTranscodeOptions extends BaseTranscodeOptions { + type: 'video' +} + +interface MergeAudioTranscodeOptions extends BaseTranscodeOptions { + type: 'merge-audio' + audioPath: string +} + +interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions { + type: 'only-audio' +} + +type TranscodeOptions = HLSTranscodeOptions + | VideoTranscodeOptions + | MergeAudioTranscodeOptions + | OnlyAudioTranscodeOptions + | QuickTranscodeOptions + function transcode (options: TranscodeOptions) { return new Promise(async (res, rej) => { try { let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) .output(options.outputPath) - if (options.doQuickTranscode) { - if (options.hlsPlaylist) { - throw(Error("Quick transcode and HLS can't be used at the same time")) - } - - command - .format('mp4') - .addOption('-c:v copy') - .addOption('-c:a copy') - .outputOption('-map_metadata -1') // strip all metadata - .outputOption('-movflags faststart') - } else if (options.hlsPlaylist) { + if (options.type === 'quick-transcode') { + command = await buildQuickTranscodeCommand(command) + } else if (options.type === 'hls') { command = await buildHLSCommand(command, options) + } else if (options.type === 'merge-audio') { + command = await buildAudioMergeCommand(command, options) + } else if (options.type === 'only-audio') { + command = await buildOnlyAudioCommand(command, options) } else { command = await buildx264Command(command, options) } @@ -163,7 +226,7 @@ function transcode (options: TranscodeOptions) { return rej(err) }) .on('end', () => { - return onTranscodingSuccess(options) + return fixHLSPlaylistIfNeeded(options) .then(() => res()) .catch(err => rej(err)) }) @@ -183,11 +246,13 @@ async function canDoQuickTranscode (path: string): Promise { const resolution = await getVideoFileResolution(path) // check video params + if (videoStream == null) return false if (videoStream[ 'codec_name' ] !== 'h264') return false + if (videoStream[ 'pix_fmt' ] !== 'yuv420p') return false if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false if (bitRate > getMaxBitrate(resolution.videoFileResolution, fps, VIDEO_TRANSCODING_FPS)) return false - // check audio params (if audio stream exists) + // check audio params (if audio stream exists) if (parsedAudio.audioStream) { if (parsedAudio.audioStream[ 'codec_name' ] !== 'aac') return false @@ -201,10 +266,14 @@ async function canDoQuickTranscode (path: string): Promise { // --------------------------------------------------------------------------- export { - getVideoFileSize, + getVideoStreamCodec, + getAudioStreamCodec, + getVideoStreamSize, getVideoFileResolution, getDurationFromVideoFile, generateImageFromVideoFile, + TranscodeOptions, + TranscodeOptionsType, transcode, getVideoFileFPS, computeResolutionsToTranscode, @@ -226,7 +295,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco fps = VIDEO_TRANSCODING_FPS.AVERAGE } - command = await presetH264(command, options.resolution, fps) + command = await presetH264(command, options.inputPath, options.resolution, fps) if (options.resolution !== undefined) { // '?x720' or '720x?' for example @@ -245,11 +314,40 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco return command } -async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { - const videoPath = getHLSVideoPath(options) +async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) { + command = command.loop(undefined) + + command = await presetH264VeryFast(command, options.audioPath, options.resolution) + command = command.input(options.audioPath) + .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error + .outputOption('-tune stillimage') + .outputOption('-shortest') + + return command +} + +async function buildOnlyAudioCommand (command: ffmpeg.FfmpegCommand, options: OnlyAudioTranscodeOptions) { + command = await presetOnlyAudio(command) + + return command +} + +async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) { command = await presetCopy(command) + command = command.outputOption('-map_metadata -1') // strip all metadata + .outputOption('-movflags faststart') + + return command +} + +async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { + const videoPath = getHLSVideoPath(options) + + if (options.copyCodecs) command = await presetCopy(command) + else command = await buildx264Command(command, options) + command = command.outputOption('-hls_time 4') .outputOption('-hls_list_size 0') .outputOption('-hls_playlist_type vod') @@ -261,19 +359,19 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod return command } -function getHLSVideoPath (options: TranscodeOptions) { +function getHLSVideoPath (options: HLSTranscodeOptions) { return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` } -async function onTranscodingSuccess (options: TranscodeOptions) { - if (!options.hlsPlaylist) return +async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { + if (options.type !== 'hls') return - // Fix wrong mapping with some ffmpeg versions const fileContent = await readFile(options.outputPath) const videoFileName = options.hlsPlaylist.videoFilename const videoFilePath = getHLSVideoPath(options) + // Fix wrong mapping with some ffmpeg versions const newContent = fileContent.toString() .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) @@ -286,9 +384,7 @@ function getVideoStreamFromFile (path: string) { if (err) return rej(err) const videoStream = metadata.streams.find(s => s.codec_type === 'video') - if (!videoStream) return rej(new Error('Cannot find video stream of ' + path)) - - return res(videoStream) + return res(videoStream || null) }) }) } @@ -300,44 +396,27 @@ function getVideoStreamFromFile (path: string) { * and quality. Superfast and ultrafast will give you better * performance, but then quality is noticeably worse. */ -async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise { - let localCommand = await presetH264(command, resolution, fps) +async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) { + let localCommand = await presetH264(command, input, resolution, fps) + localCommand = localCommand.outputOption('-preset:v veryfast') - .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) + /* MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html Our target situation is closer to a livestream than a stream, since we want to reduce as much a possible the encoding burden, - altough not to the point of a livestream where there is a hard + although not to the point of a livestream where there is a hard constraint on the frames per second to be encoded. - - why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'? - Make up for most of the loss of grain and macroblocking - with less computing power. */ return localCommand } -/** - * A preset optimised for a stillimage audio video - */ -async function presetStillImageWithAudio ( - command: ffmpeg.FfmpegCommand, - resolution: VideoResolution, - fps: number -): Promise { - let localCommand = await presetH264VeryFast(command, resolution, fps) - localCommand = localCommand.outputOption('-tune stillimage') - - return localCommand -} - /** * A toolbox to play with audio */ namespace audio { - export const get = (option: ffmpeg.FfmpegCommand | string) => { + export const get = (videoPath: string) => { // without position, ffprobe considers the last input only // we make it consider the first input only // if you pass a file path to pos, then ffprobe acts on that file directly @@ -347,7 +426,7 @@ namespace audio { if (err) return rej(err) if ('streams' in data) { - const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') + const audioStream = data.streams.find(stream => stream[ 'codec_type' ] === 'audio') if (audioStream) { return res({ absolutePath: data.format.filename, @@ -359,25 +438,22 @@ namespace audio { return res({ absolutePath: data.format.filename }) } - if (typeof option === 'string') { - return ffmpeg.ffprobe(option, parseFfprobe) - } - - return option.ffprobe(parseFfprobe) + return ffmpeg.ffprobe(videoPath, parseFfprobe) }) } export namespace bitrate { const baseKbitrate = 384 - const toBits = (kbits: number): number => { return kbits * 8000 } + const toBits = (kbits: number) => kbits * 8000 export const aac = (bitrate: number): number => { switch (true) { - case bitrate > toBits(baseKbitrate): - return baseKbitrate - default: - return -1 // we interpret it as a signal to copy the audio stream as is + case bitrate > toBits(baseKbitrate): + return baseKbitrate + + default: + return -1 // we interpret it as a signal to copy the audio stream as is } } @@ -388,12 +464,14 @@ namespace audio { made here are not made to be accurate, especially with good mp3 encoders. */ switch (true) { - case bitrate <= toBits(192): - return 128 - case bitrate <= toBits(384): - return 256 - default: - return baseKbitrate + case bitrate <= toBits(192): + return 128 + + case bitrate <= toBits(384): + return 256 + + default: + return baseKbitrate } } } @@ -405,18 +483,18 @@ namespace audio { * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr */ -async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise { +async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) { let localCommand = command .format('mp4') .videoCodec('libx264') - .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution - .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it + .outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution + .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) .outputOption('-map_metadata -1') // strip all metadata .outputOption('-movflags faststart') - const parsedAudio = await audio.get(localCommand) + const parsedAudio = await audio.get(input) if (!parsedAudio.audioStream) { localCommand = localCommand.noAudio() @@ -425,28 +503,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol .audioCodec('libfdk_aac') .audioQuality(5) } else { - // we try to reduce the ceiling bitrate by making rough correspondances of bitrates + // we try to reduce the ceiling bitrate by making rough matches of bitrates // of course this is far from perfect, but it might save some space in the end + localCommand = localCommand.audioCodec('aac') + const audioCodecName = parsedAudio.audioStream[ 'codec_name' ] - let bitrate: number - if (audio.bitrate[ audioCodecName ]) { - localCommand = localCommand.audioCodec('aac') - bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) + if (audio.bitrate[ audioCodecName ]) { + const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) } } - // Constrained Encoding (VBV) - // https://slhck.info/video/2017/03/01/rate-control.html - // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate - const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) - localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) - - // Keyframe interval of 2 seconds for faster seeking and resolution switching. - // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html - // https://superuser.com/a/908325 - localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) + if (fps) { + // Constrained Encoding (VBV) + // https://slhck.info/video/2017/03/01/rate-control.html + // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate + const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) + localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ]) + + // Keyframe interval of 2 seconds for faster seeking and resolution switching. + // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html + // https://superuser.com/a/908325 + localCommand = localCommand.outputOption(`-g ${fps * 2}`) + } return localCommand } @@ -457,3 +537,10 @@ async function presetCopy (command: ffmpeg.FfmpegCommand): Promise { + return command + .format('mp4') + .audioCodec('copy') + .noVideo() +}