X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fhelpers%2Fffmpeg-utils.ts;h=bf6408d3e1c63b116c239b779b0622f47209766e;hb=040d6896a3cd5622e78cccdedd8cce2afcf49a31;hp=e297108df2bc46f7256c7becec1e63456be83e6c;hpb=9252a33d115bba85adcfbc18ab3725924642871c;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index e297108df..bf6408d3e 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -1,14 +1,21 @@ import * as ffmpeg from 'fluent-ffmpeg' import { readFile, remove, writeFile } from 'fs-extra' import { dirname, join } from 'path' -import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' +import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_ENCODERS } from '@server/initializers/constants' +import { VideoResolution } from '../../shared/models/videos' import { checkFFmpegEncoders } from '../initializers/checker-before-init' import { CONFIG } from '../initializers/config' -import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_ENCODERS, VIDEO_TRANSCODING_FPS } from '../initializers/constants' -import { getAudioStream, getClosestFramerateStandard, getVideoFileFPS } from './ffprobe-utils' +import { computeFPS, getAudioStream, getVideoFileFPS } from './ffprobe-utils' import { processImage } from './image-utils' import { logger } from './logger' +/** + * + * Functions that run transcoding/muxing ffmpeg processes + * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts + * + */ + // --------------------------------------------------------------------------- // Encoder options // --------------------------------------------------------------------------- @@ -19,11 +26,13 @@ export type EncoderOptionsBuilder = (params: { input: string resolution: VideoResolution fps?: number + streamNum?: number }) => Promise | EncoderOptions // Options types export interface EncoderOptions { + copy?: boolean outputOptions: string[] } @@ -37,7 +46,7 @@ export interface EncoderProfile { export type AvailableEncoders = { [ id in 'live' | 'vod' ]: { - [ encoder in 'libx264' | 'aac' | 'libfdkAAC' ]: EncoderProfile + [ encoder in 'libx264' | 'aac' | 'libfdk_aac' ]?: EncoderProfile } } @@ -55,35 +64,14 @@ function convertWebPToJPG (path: string, destination: string): Promise { function processGIF ( path: string, destination: string, - newSize: { width: number, height: number }, - keepOriginal = false + newSize: { width: number, height: number } ): Promise { - return new Promise(async (res, rej) => { - if (path === destination) { - throw new Error('FFmpeg needs an input path different that the output path.') - } - - logger.debug('Processing gif %s to %s.', path, destination) + const command = ffmpeg(path) + .fps(20) + .size(`${newSize.width}x${newSize.height}`) + .output(destination) - try { - const command = ffmpeg(path) - .fps(20) - .size(`${newSize.width}x${newSize.height}`) - .output(destination) - - command.on('error', (err, stdout, stderr) => { - logger.error('Error in ffmpeg gif resizing process.', { stdout, stderr }) - return rej(err) - }) - .on('end', async () => { - if (keepOriginal !== true) await remove(path) - res() - }) - .run() - } catch (err) { - return rej(err) - } - }) + return runCommand(command) } async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) { @@ -122,7 +110,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima // Transcode meta function // --------------------------------------------------------------------------- -type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' +type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' interface BaseTranscodeOptions { type: TranscodeOptionsType @@ -146,6 +134,16 @@ interface HLSTranscodeOptions extends BaseTranscodeOptions { } } +interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions { + type: 'hls-from-ts' + + isAAC: boolean + + hlsPlaylist: { + videoFilename: string + } +} + interface QuickTranscodeOptions extends BaseTranscodeOptions { type: 'quick-transcode' } @@ -165,6 +163,7 @@ interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions { type TranscodeOptions = HLSTranscodeOptions + | HLSFromTSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | OnlyAudioTranscodeOptions @@ -175,6 +174,7 @@ const builders: { } = { 'quick-transcode': buildQuickTranscodeCommand, 'hls': buildHLSVODCommand, + 'hls-from-ts': buildHLSVODFromTSCommand, 'merge-audio': buildAudioMergeCommand, 'only-audio': buildOnlyAudioCommand, 'video': buildx264VODCommand @@ -183,7 +183,7 @@ const builders: { async function transcode (options: TranscodeOptions) { logger.debug('Will run transcode.', { options }) - let command = getFFmpeg(options.inputPath) + let command = getFFmpeg(options.inputPath, 'vod') .output(options.outputPath) command = await builders[options.type](command, options) @@ -197,9 +197,19 @@ async function transcode (options: TranscodeOptions) { // Live muxing/transcoding functions // --------------------------------------------------------------------------- -function getLiveTranscodingCommand (rtmpUrl: string, outPath: string, resolutions: number[], fps: number, deleteSegments: boolean) { - const command = getFFmpeg(rtmpUrl) - command.inputOption('-fflags nobuffer') +async function getLiveTranscodingCommand (options: { + rtmpUrl: string + outPath: string + resolutions: number[] + fps: number + + availableEncoders: AvailableEncoders + profile: string +}) { + const { rtmpUrl, outPath, resolutions, fps, availableEncoders, profile } = options + const input = rtmpUrl + + const command = getFFmpeg(input, 'live') const varStreamMap: string[] = [] @@ -219,67 +229,86 @@ function getLiveTranscodingCommand (rtmpUrl: string, outPath: string, resolution })) ]) - addEncoderDefaultParams(command, 'libx264', fps) - command.outputOption('-preset superfast') + command.outputOption('-sc_threshold 0') + + addDefaultEncoderGlobalParams({ command }) for (let i = 0; i < resolutions.length; i++) { const resolution = resolutions[i] + const resolutionFPS = computeFPS(fps, resolution) + + const baseEncoderBuilderParams = { + input, + availableEncoders, + profile, + fps: resolutionFPS, + resolution, + streamNum: i, + videoType: 'live' as 'live' + } + + { + const builderResult = await getEncoderBuilderResult(Object.assign({}, baseEncoderBuilderParams, { streamType: 'VIDEO' })) + if (!builderResult) { + throw new Error('No available live video encoder found') + } + + command.outputOption(`-map [vout${resolution}]`) + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) - command.outputOption(`-map [vout${resolution}]`) - command.outputOption(`-c:v:${i} libx264`) - command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)}`) + logger.debug('Apply ffmpeg live video params from %s.', builderResult.encoder, builderResult) - command.outputOption(`-map a:0`) - command.outputOption(`-c:a:${i} aac`) + command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) + command.addOutputOptions(builderResult.result.outputOptions) + } + + { + const builderResult = await getEncoderBuilderResult(Object.assign({}, baseEncoderBuilderParams, { streamType: 'AUDIO' })) + if (!builderResult) { + throw new Error('No available live audio encoder found') + } + + command.outputOption('-map a:0') + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) + + logger.debug('Apply ffmpeg live audio params from %s.', builderResult.encoder, builderResult) + + command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) + command.addOutputOptions(builderResult.result.outputOptions) + } varStreamMap.push(`v:${i},a:${i}`) } - addDefaultLiveHLSParams(command, outPath, deleteSegments) + addDefaultLiveHLSParams(command, outPath) command.outputOption('-var_stream_map', varStreamMap.join(' ')) return command } -function getLiveMuxingCommand (rtmpUrl: string, outPath: string, deleteSegments: boolean) { - const command = getFFmpeg(rtmpUrl) - command.inputOption('-fflags nobuffer') +function getLiveMuxingCommand (rtmpUrl: string, outPath: string) { + const command = getFFmpeg(rtmpUrl, 'live') command.outputOption('-c:v copy') command.outputOption('-c:a copy') command.outputOption('-map 0:a?') command.outputOption('-map 0:v?') - addDefaultLiveHLSParams(command, outPath, deleteSegments) + addDefaultLiveHLSParams(command, outPath) return command } -async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: string[], outputPath: string) { - const concatFilePath = join(hlsDirectory, 'concat.txt') - - function cleaner () { - remove(concatFilePath) - .catch(err => logger.error('Cannot remove concat file in %s.', hlsDirectory, { err })) +function buildStreamSuffix (base: string, streamNum?: number) { + if (streamNum !== undefined) { + return `${base}:${streamNum}` } - // First concat the ts files to a mp4 file - const content = segmentFiles.map(f => 'file ' + f) - .join('\n') - - await writeFile(concatFilePath, content + '\n') - - const command = getFFmpeg(concatFilePath) - command.inputOption('-safe 0') - command.inputOption('-f concat') - - command.outputOption('-c:v copy') - command.audioFilter('aresample=async=1:first_pts=0') - command.output(outputPath) - - return runCommand(command, cleaner) + return base } // --------------------------------------------------------------------------- @@ -287,13 +316,13 @@ async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: s export { getLiveTranscodingCommand, getLiveMuxingCommand, + buildStreamSuffix, convertWebPToJPG, processGIF, generateImageFromVideoFile, TranscodeOptions, TranscodeOptionsType, - transcode, - hlsPlaylistToFragmentedMP4 + transcode } // --------------------------------------------------------------------------- @@ -302,29 +331,48 @@ export { // Default options // --------------------------------------------------------------------------- -function addEncoderDefaultParams (command: ffmpeg.FfmpegCommand, encoder: 'libx264' | string, fps?: number) { - if (encoder !== 'libx264') return - - command.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('-max_muxing_queue_size 1024') // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 - // 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 - .outputOption('-g ' + (fps * 2)) +function addDefaultEncoderGlobalParams (options: { + command: ffmpeg.FfmpegCommand +}) { + const { command } = options + + // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 + command.outputOption('-max_muxing_queue_size 1024') + // strip all metadata + .outputOption('-map_metadata -1') + // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it + .outputOption('-b_strategy 1') + // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 + .outputOption('-bf 16') + // allows import of source material with incompatible pixel formats (e.g. MJPEG video) + .outputOption('-pix_fmt yuv420p') +} + +function addDefaultEncoderParams (options: { + command: ffmpeg.FfmpegCommand + encoder: 'libx264' | string + streamNum?: number + fps?: number +}) { + const { command, encoder, fps, streamNum } = options + + if (encoder === 'libx264') { + // 3.1 is the minimal resource allocation for our highest supported resolution + command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1') + + if (fps) { + // 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 + command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2)) + } + } } -function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { +function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) { command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) - - if (deleteSegments === true) { - command.outputOption('-hls_flags delete_segments') - } - + command.outputOption('-hls_flags delete_segments+independent_segments') command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) command.outputOption('-master_pl_name master.m3u8') command.outputOption(`-f hls`) @@ -338,30 +386,17 @@ function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string async function buildx264VODCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { let fps = await getVideoFileFPS(options.inputPath) - if ( - // On small/medium resolutions, limit FPS - options.resolution !== undefined && - options.resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && - fps > VIDEO_TRANSCODING_FPS.AVERAGE - ) { - // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value - fps = getClosestFramerateStandard(fps, 'STANDARD') - } + fps = computeFPS(fps, options.resolution) command = await presetVideo(command, options.inputPath, options, fps) if (options.resolution !== undefined) { // '?x720' or '720x?' for example - const size = options.isPortraitMode === true ? `${options.resolution}x?` : `?x${options.resolution}` - command = command.size(size) - } + const size = options.isPortraitMode === true + ? `${options.resolution}x?` + : `?x${options.resolution}` - if (fps) { - // Hard FPS limits - if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD') - else if (fps < VIDEO_TRANSCODING_FPS.MIN) fps = VIDEO_TRANSCODING_FPS.MIN - - command = command.withFPS(fps) + command = command.size(size) } return command @@ -372,13 +407,6 @@ async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: M command = await presetVideo(command, options.audioPath, options) - /* - 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, - although not to the point of a livestream where there is a hard - constraint on the frames per second to be encoded. - */ command.outputOption('-preset:v veryfast') command = command.input(options.audioPath) @@ -404,6 +432,16 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) { return command } +function addCommonHLSVODCommandOptions (command: ffmpeg.FfmpegCommand, outputPath: string) { + return command.outputOption('-hls_time 4') + .outputOption('-hls_list_size 0') + .outputOption('-hls_playlist_type vod') + .outputOption('-hls_segment_filename ' + outputPath) + .outputOption('-hls_segment_type fmp4') + .outputOption('-f hls') + .outputOption('-hls_flags single_file') +} + async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { const videoPath = getHLSVideoPath(options) @@ -411,19 +449,29 @@ async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTr else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) else command = await buildx264VODCommand(command, options) - command = command.outputOption('-hls_time 4') - .outputOption('-hls_list_size 0') - .outputOption('-hls_playlist_type vod') - .outputOption('-hls_segment_filename ' + videoPath) - .outputOption('-hls_segment_type fmp4') - .outputOption('-f hls') - .outputOption('-hls_flags single_file') + addCommonHLSVODCommandOptions(command, videoPath) + + return command +} + +async function buildHLSVODFromTSCommand (command: ffmpeg.FfmpegCommand, options: HLSFromTSTranscodeOptions) { + const videoPath = getHLSVideoPath(options) + + command.outputOption('-c copy') + + if (options.isAAC) { + // Required for example when copying an AAC stream from an MPEG-TS + // Since it's a bitstream filter, we don't need to reencode the audio + command.outputOption('-bsf:a aac_adtstoasc') + } + + addCommonHLSVODCommandOptions(command, videoPath) return command } async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { - if (options.type !== 'hls') return + if (options.type !== 'hls' && options.type !== 'hls-from-ts') return const fileContent = await readFile(options.outputPath) @@ -437,7 +485,7 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) { await writeFile(options.outputPath, newContent) } -function getHLSVideoPath (options: HLSTranscodeOptions) { +function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` } @@ -445,6 +493,49 @@ function getHLSVideoPath (options: HLSTranscodeOptions) { // Transcoding presets // --------------------------------------------------------------------------- +async function getEncoderBuilderResult (options: { + streamType: string + input: string + + availableEncoders: AvailableEncoders + profile: string + + videoType: 'vod' | 'live' + + resolution: number + fps?: number + streamNum?: number +}) { + const { availableEncoders, input, profile, resolution, streamType, fps, streamNum, videoType } = options + + const encodersToTry: string[] = VIDEO_TRANSCODING_ENCODERS[streamType] + + for (const encoder of encodersToTry) { + if (!(await checkFFmpegEncoders()).get(encoder) || !availableEncoders[videoType][encoder]) continue + + const builderProfiles: EncoderProfile = availableEncoders[videoType][encoder] + let builder = builderProfiles[profile] + + if (!builder) { + logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder) + builder = builderProfiles.default + } + + const result = await builder({ input, resolution: resolution, fps, streamNum }) + + return { + result, + + // If we don't have output options, then copy the input stream + encoder: result.copy === true + ? 'copy' + : encoder + } + } + + return null +} + async function presetVideo ( command: ffmpeg.FfmpegCommand, input: string, @@ -455,53 +546,47 @@ async function presetVideo ( .format('mp4') .outputOption('-movflags faststart') + addDefaultEncoderGlobalParams({ command }) + // Audio encoder const parsedAudio = await getAudioStream(input) let streamsToProcess = [ 'AUDIO', 'VIDEO' ] - const streamsFound = { - AUDIO: '', - VIDEO: '' - } if (!parsedAudio.audioStream) { localCommand = localCommand.noAudio() streamsToProcess = [ 'VIDEO' ] } - for (const stream of streamsToProcess) { - const encodersToTry: string[] = VIDEO_TRANSCODING_ENCODERS[stream] - - for (const encoder of encodersToTry) { - if (!(await checkFFmpegEncoders()).get(encoder)) continue - - const builderProfiles: EncoderProfile = transcodeOptions.availableEncoders.vod[encoder] - let builder = builderProfiles[transcodeOptions.profile] - - if (!builder) { - logger.debug('Profile %s for encoder %s not available. Fallback to default.', transcodeOptions.profile, encoder) - builder = builderProfiles.default - } - - const builderResult = await builder({ input, resolution: transcodeOptions.resolution, fps }) - - logger.debug('Apply ffmpeg params from %s.', encoder, builderResult) + for (const streamType of streamsToProcess) { + const { profile, resolution, availableEncoders } = transcodeOptions + + const builderResult = await getEncoderBuilderResult({ + streamType, + input, + resolution, + availableEncoders, + profile, + fps, + videoType: 'vod' as 'vod' + }) - localCommand.outputOptions(builderResult.outputOptions) + if (!builderResult) { + throw new Error('No available encoder found for stream ' + streamType) + } - addEncoderDefaultParams(localCommand, encoder) + logger.debug('Apply ffmpeg params from %s.', builderResult.encoder, builderResult) - streamsFound[stream] = encoder - break + if (streamType === 'VIDEO') { + localCommand.videoCodec(builderResult.encoder) + } else if (streamType === 'AUDIO') { + localCommand.audioCodec(builderResult.encoder) } - if (!streamsFound[stream]) { - throw new Error('No available encoder found ' + encodersToTry.join(', ')) - } + command.addOutputOptions(builderResult.result.outputOptions) + addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps }) } - localCommand.videoCodec(streamsFound.VIDEO) - return localCommand } @@ -523,13 +608,17 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand { // Utils // --------------------------------------------------------------------------- -function getFFmpeg (input: string) { +function getFFmpeg (input: string, type: 'live' | 'vod') { // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR }) - if (CONFIG.TRANSCODING.THREADS > 0) { + const threads = type === 'live' + ? CONFIG.LIVE.TRANSCODING.THREADS + : CONFIG.TRANSCODING.THREADS + + if (threads > 0) { // If we don't set any threads ffmpeg will chose automatically - command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS) + command.outputOption('-threads ' + threads) } return command