From 0c9668f77901e7540e2c7045eb0f2974a4842a69 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 21 Apr 2023 14:55:10 +0200 Subject: Implement remote runner jobs in server Move ffmpeg functions to @shared --- shared/ffmpeg/ffmpeg-command-wrapper.ts | 234 +++++++++++++++++++++++++++++ shared/ffmpeg/ffmpeg-edition.ts | 239 +++++++++++++++++++++++++++++ shared/ffmpeg/ffmpeg-images.ts | 59 ++++++++ shared/ffmpeg/ffmpeg-live.ts | 184 +++++++++++++++++++++++ shared/ffmpeg/ffmpeg-utils.ts | 17 +++ shared/ffmpeg/ffmpeg-version.ts | 24 +++ shared/ffmpeg/ffmpeg-vod.ts | 256 ++++++++++++++++++++++++++++++++ shared/ffmpeg/ffprobe.ts | 184 +++++++++++++++++++++++ shared/ffmpeg/index.ts | 8 + shared/ffmpeg/shared/encoder-options.ts | 39 +++++ shared/ffmpeg/shared/index.ts | 2 + shared/ffmpeg/shared/presets.ts | 93 ++++++++++++ 12 files changed, 1339 insertions(+) create mode 100644 shared/ffmpeg/ffmpeg-command-wrapper.ts create mode 100644 shared/ffmpeg/ffmpeg-edition.ts create mode 100644 shared/ffmpeg/ffmpeg-images.ts create mode 100644 shared/ffmpeg/ffmpeg-live.ts create mode 100644 shared/ffmpeg/ffmpeg-utils.ts create mode 100644 shared/ffmpeg/ffmpeg-version.ts create mode 100644 shared/ffmpeg/ffmpeg-vod.ts create mode 100644 shared/ffmpeg/ffprobe.ts create mode 100644 shared/ffmpeg/index.ts create mode 100644 shared/ffmpeg/shared/encoder-options.ts create mode 100644 shared/ffmpeg/shared/index.ts create mode 100644 shared/ffmpeg/shared/presets.ts (limited to 'shared/ffmpeg') diff --git a/shared/ffmpeg/ffmpeg-command-wrapper.ts b/shared/ffmpeg/ffmpeg-command-wrapper.ts new file mode 100644 index 000000000..7a8c19d4b --- /dev/null +++ b/shared/ffmpeg/ffmpeg-command-wrapper.ts @@ -0,0 +1,234 @@ +import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg' +import { pick, promisify0 } from '@shared/core-utils' +import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models' + +type FFmpegLogger = { + info: (msg: string, obj?: any) => void + debug: (msg: string, obj?: any) => void + warn: (msg: string, obj?: any) => void + error: (msg: string, obj?: any) => void +} + +export interface FFmpegCommandWrapperOptions { + availableEncoders?: AvailableEncoders + profile?: string + + niceness: number + tmpDirectory: string + threads: number + + logger: FFmpegLogger + lTags?: { tags: string[] } + + updateJobProgress?: (progress?: number) => void +} + +export class FFmpegCommandWrapper { + private static supportedEncoders: Map + + private readonly availableEncoders: AvailableEncoders + private readonly profile: string + + private readonly niceness: number + private readonly tmpDirectory: string + private readonly threads: number + + private readonly logger: FFmpegLogger + private readonly lTags: { tags: string[] } + + private readonly updateJobProgress: (progress?: number) => void + + private command: FfmpegCommand + + constructor (options: FFmpegCommandWrapperOptions) { + this.availableEncoders = options.availableEncoders + this.profile = options.profile + this.niceness = options.niceness + this.tmpDirectory = options.tmpDirectory + this.threads = options.threads + this.logger = options.logger + this.lTags = options.lTags || { tags: [] } + this.updateJobProgress = options.updateJobProgress + } + + getAvailableEncoders () { + return this.availableEncoders + } + + getProfile () { + return this.profile + } + + getCommand () { + return this.command + } + + // --------------------------------------------------------------------------- + + debugLog (msg: string, meta: any) { + this.logger.debug(msg, { ...meta, ...this.lTags }) + } + + // --------------------------------------------------------------------------- + + buildCommand (input: string) { + if (this.command) throw new Error('Command is already built') + + // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems + this.command = ffmpeg(input, { + niceness: this.niceness, + cwd: this.tmpDirectory + }) + + if (this.threads > 0) { + // If we don't set any threads ffmpeg will chose automatically + this.command.outputOption('-threads ' + this.threads) + } + + return this.command + } + + async runCommand (options: { + silent?: boolean // false by default + } = {}) { + const { silent = false } = options + + return new Promise((res, rej) => { + let shellCommand: string + + this.command.on('start', cmdline => { shellCommand = cmdline }) + + this.command.on('error', (err, stdout, stderr) => { + if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags }) + + rej(err) + }) + + this.command.on('end', (stdout, stderr) => { + this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags }) + + res() + }) + + if (this.updateJobProgress) { + this.command.on('progress', progress => { + if (!progress.percent) return + + // Sometimes ffmpeg returns an invalid progress + let percent = Math.round(progress.percent) + if (percent < 0) percent = 0 + if (percent > 100) percent = 100 + + this.updateJobProgress(percent) + }) + } + + this.command.run() + }) + } + + // --------------------------------------------------------------------------- + + static resetSupportedEncoders () { + FFmpegCommandWrapper.supportedEncoders = undefined + } + + // Run encoder builder depending on available encoders + // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one + // If the default one does not exist, check the next encoder + async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & { + streamType: 'video' | 'audio' + input: string + + videoType: 'vod' | 'live' + }) { + if (!this.availableEncoders) { + throw new Error('There is no available encoders') + } + + const { streamType, videoType } = options + + const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType] + const encoders = this.availableEncoders.available[videoType] + + for (const encoder of encodersToTry) { + if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) { + this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags) + continue + } + + if (!encoders[encoder]) { + this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags) + continue + } + + // An object containing available profiles for this encoder + const builderProfiles: EncoderProfile = encoders[encoder] + let builder = builderProfiles[this.profile] + + if (!builder) { + this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags) + builder = builderProfiles.default + + if (!builder) { + this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags) + continue + } + } + + const result = await builder( + pick(options, [ + 'input', + 'canCopyAudio', + 'canCopyVideo', + 'resolution', + 'inputBitrate', + 'fps', + 'inputRatio', + 'streamNum' + ]) + ) + + return { + result, + + // If we don't have output options, then copy the input stream + encoder: result.copy === true + ? 'copy' + : encoder + } + } + + return null + } + + // Detect supported encoders by ffmpeg + private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise> { + if (FFmpegCommandWrapper.supportedEncoders !== undefined) { + return FFmpegCommandWrapper.supportedEncoders + } + + const getAvailableEncodersPromise = promisify0(getAvailableEncoders) + const availableFFmpegEncoders = await getAvailableEncodersPromise() + + const searchEncoders = new Set() + for (const type of [ 'live', 'vod' ]) { + for (const streamType of [ 'audio', 'video' ]) { + for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) { + searchEncoders.add(encoder) + } + } + } + + const supportedEncoders = new Map() + + for (const searchEncoder of searchEncoders) { + supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined) + } + + this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags }) + + FFmpegCommandWrapper.supportedEncoders = supportedEncoders + return supportedEncoders + } +} diff --git a/shared/ffmpeg/ffmpeg-edition.ts b/shared/ffmpeg/ffmpeg-edition.ts new file mode 100644 index 000000000..724ca1ea9 --- /dev/null +++ b/shared/ffmpeg/ffmpeg-edition.ts @@ -0,0 +1,239 @@ +import { FilterSpecification } from 'fluent-ffmpeg' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' +import { presetVOD } from './shared/presets' +import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe' + +export class FFmpegEdition { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + async cutVideo (options: { + inputPath: string + outputPath: string + start?: number + end?: number + }) { + const { inputPath, outputPath } = options + + const mainProbe = await ffprobePromise(inputPath) + const fps = await getVideoStreamFPS(inputPath, mainProbe) + const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) + + const command = this.commandWrapper.buildCommand(inputPath) + .output(outputPath) + + await presetVOD({ + commandWrapper: this.commandWrapper, + input: inputPath, + resolution, + fps, + canCopyAudio: false, + canCopyVideo: false + }) + + if (options.start) { + command.outputOption('-ss ' + options.start) + } + + if (options.end) { + command.outputOption('-to ' + options.end) + } + + await this.commandWrapper.runCommand() + } + + async addWatermark (options: { + inputPath: string + watermarkPath: string + outputPath: string + + videoFilters: { + watermarkSizeRatio: number + horitonzalMarginRatio: number + verticalMarginRatio: number + } + }) { + const { watermarkPath, inputPath, outputPath, videoFilters } = options + + const videoProbe = await ffprobePromise(inputPath) + const fps = await getVideoStreamFPS(inputPath, videoProbe) + const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe) + + const command = this.commandWrapper.buildCommand(inputPath) + .output(outputPath) + + command.input(watermarkPath) + + await presetVOD({ + commandWrapper: this.commandWrapper, + input: inputPath, + resolution, + fps, + canCopyAudio: true, + canCopyVideo: false + }) + + const complexFilter: FilterSpecification[] = [ + // Scale watermark + { + inputs: [ '[1]', '[0]' ], + filter: 'scale2ref', + options: { + w: 'oh*mdar', + h: `ih*${videoFilters.watermarkSizeRatio}` + }, + outputs: [ '[watermark]', '[video]' ] + }, + + { + inputs: [ '[video]', '[watermark]' ], + filter: 'overlay', + options: { + x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`, + y: `main_h * ${videoFilters.verticalMarginRatio}` + } + } + ] + + command.complexFilter(complexFilter) + + await this.commandWrapper.runCommand() + } + + async addIntroOutro (options: { + inputPath: string + introOutroPath: string + outputPath: string + type: 'intro' | 'outro' + }) { + const { introOutroPath, inputPath, outputPath, type } = options + + const mainProbe = await ffprobePromise(inputPath) + const fps = await getVideoStreamFPS(inputPath, mainProbe) + const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) + const mainHasAudio = await hasAudioStream(inputPath, mainProbe) + + const introOutroProbe = await ffprobePromise(introOutroPath) + const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe) + + const command = this.commandWrapper.buildCommand(inputPath) + .output(outputPath) + + command.input(introOutroPath) + + if (!introOutroHasAudio && mainHasAudio) { + const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe) + + command.input('anullsrc') + command.withInputFormat('lavfi') + command.withInputOption('-t ' + duration) + } + + await presetVOD({ + commandWrapper: this.commandWrapper, + input: inputPath, + resolution, + fps, + canCopyAudio: false, + canCopyVideo: false + }) + + // Add black background to correctly scale intro/outro with padding + const complexFilter: FilterSpecification[] = [ + { + inputs: [ '1', '0' ], + filter: 'scale2ref', + options: { + w: 'iw', + h: `ih` + }, + outputs: [ 'intro-outro', 'main' ] + }, + { + inputs: [ 'intro-outro', 'main' ], + filter: 'scale2ref', + options: { + w: 'iw', + h: `ih` + }, + outputs: [ 'to-scale', 'main' ] + }, + { + inputs: 'to-scale', + filter: 'drawbox', + options: { + t: 'fill' + }, + outputs: [ 'to-scale-bg' ] + }, + { + inputs: [ '1', 'to-scale-bg' ], + filter: 'scale2ref', + options: { + w: 'iw', + h: 'ih', + force_original_aspect_ratio: 'decrease', + flags: 'spline' + }, + outputs: [ 'to-scale', 'to-scale-bg' ] + }, + { + inputs: [ 'to-scale-bg', 'to-scale' ], + filter: 'overlay', + options: { + x: '(main_w - overlay_w)/2', + y: '(main_h - overlay_h)/2' + }, + outputs: 'intro-outro-resized' + } + ] + + const concatFilter = { + inputs: [], + filter: 'concat', + options: { + n: 2, + v: 1, + unsafe: 1 + }, + outputs: [ 'v' ] + } + + const introOutroFilterInputs = [ 'intro-outro-resized' ] + const mainFilterInputs = [ 'main' ] + + if (mainHasAudio) { + mainFilterInputs.push('0:a') + + if (introOutroHasAudio) { + introOutroFilterInputs.push('1:a') + } else { + // Silent input + introOutroFilterInputs.push('2:a') + } + } + + if (type === 'intro') { + concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ] + } else { + concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ] + } + + if (mainHasAudio) { + concatFilter.options['a'] = 1 + concatFilter.outputs.push('a') + + command.outputOption('-map [a]') + } + + command.outputOption('-map [v]') + + complexFilter.push(concatFilter) + command.complexFilter(complexFilter) + + await this.commandWrapper.runCommand() + } +} diff --git a/shared/ffmpeg/ffmpeg-images.ts b/shared/ffmpeg/ffmpeg-images.ts new file mode 100644 index 000000000..2db63bd8b --- /dev/null +++ b/shared/ffmpeg/ffmpeg-images.ts @@ -0,0 +1,59 @@ +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' + +export class FFmpegImage { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + convertWebPToJPG (options: { + path: string + destination: string + }): Promise { + const { path, destination } = options + + this.commandWrapper.buildCommand(path) + .output(destination) + + return this.commandWrapper.runCommand({ silent: true }) + } + + processGIF (options: { + path: string + destination: string + newSize: { width: number, height: number } + }): Promise { + const { path, destination, newSize } = options + + this.commandWrapper.buildCommand(path) + .fps(20) + .size(`${newSize.width}x${newSize.height}`) + .output(destination) + + return this.commandWrapper.runCommand() + } + + async generateThumbnailFromVideo (options: { + fromPath: string + folder: string + imageName: string + }) { + const { fromPath, folder, imageName } = options + + const pendingImageName = 'pending-' + imageName + + const thumbnailOptions = { + filename: pendingImageName, + count: 1, + folder + } + + return new Promise((res, rej) => { + this.commandWrapper.buildCommand(fromPath) + .on('error', rej) + .on('end', () => res(imageName)) + .thumbnail(thumbnailOptions) + }) + } +} diff --git a/shared/ffmpeg/ffmpeg-live.ts b/shared/ffmpeg/ffmpeg-live.ts new file mode 100644 index 000000000..cca4c6474 --- /dev/null +++ b/shared/ffmpeg/ffmpeg-live.ts @@ -0,0 +1,184 @@ +import { FilterSpecification } from 'fluent-ffmpeg' +import { join } from 'path' +import { pick } from '@shared/core-utils' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' +import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils' +import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared' + +export class FFmpegLive { + private readonly commandWrapper: FFmpegCommandWrapper + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + async getLiveTranscodingCommand (options: { + inputUrl: string + + outPath: string + masterPlaylistName: string + + toTranscode: { + resolution: number + fps: number + }[] + + // Input information + bitrate: number + ratio: number + hasAudio: boolean + + segmentListSize: number + segmentDuration: number + }) { + const { + inputUrl, + outPath, + toTranscode, + bitrate, + masterPlaylistName, + ratio, + hasAudio + } = options + const command = this.commandWrapper.buildCommand(inputUrl) + + const varStreamMap: string[] = [] + + const complexFilter: FilterSpecification[] = [ + { + inputs: '[v:0]', + filter: 'split', + options: toTranscode.length, + outputs: toTranscode.map(t => `vtemp${t.resolution}`) + } + ] + + command.outputOption('-sc_threshold 0') + + addDefaultEncoderGlobalParams(command) + + for (let i = 0; i < toTranscode.length; i++) { + const streamMap: string[] = [] + const { resolution, fps } = toTranscode[i] + + const baseEncoderBuilderParams = { + input: inputUrl, + + canCopyAudio: true, + canCopyVideo: true, + + inputBitrate: bitrate, + inputRatio: ratio, + + resolution, + fps, + + streamNum: i, + videoType: 'live' as 'live' + } + + { + const streamType: StreamType = 'video' + const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) + if (!builderResult) { + throw new Error('No available live video encoder found') + } + + command.outputOption(`-map [vout${resolution}]`) + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) + + this.commandWrapper.debugLog( + `Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, + { builderResult, fps, toTranscode } + ) + + command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) + applyEncoderOptions(command, builderResult.result) + + complexFilter.push({ + inputs: `vtemp${resolution}`, + filter: getScaleFilter(builderResult.result), + options: `w=-2:h=${resolution}`, + outputs: `vout${resolution}` + }) + + streamMap.push(`v:${i}`) + } + + if (hasAudio) { + const streamType: StreamType = 'audio' + const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) + if (!builderResult) { + throw new Error('No available live audio encoder found') + } + + command.outputOption('-map a:0') + + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i }) + + this.commandWrapper.debugLog( + `Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`, + { builderResult, fps, resolution } + ) + + command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) + applyEncoderOptions(command, builderResult.result) + + streamMap.push(`a:${i}`) + } + + varStreamMap.push(streamMap.join(',')) + } + + command.complexFilter(complexFilter) + + this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName }) + + command.outputOption('-var_stream_map', varStreamMap.join(' ')) + + return command + } + + getLiveMuxingCommand (options: { + inputUrl: string + outPath: string + masterPlaylistName: string + + segmentListSize: number + segmentDuration: number + }) { + const { inputUrl, outPath, masterPlaylistName } = options + + const command = this.commandWrapper.buildCommand(inputUrl) + + command.outputOption('-c:v copy') + command.outputOption('-c:a copy') + command.outputOption('-map 0:a?') + command.outputOption('-map 0:v?') + + this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName }) + + return command + } + + private addDefaultLiveHLSParams (options: { + outPath: string + masterPlaylistName: string + segmentListSize: number + segmentDuration: number + }) { + const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options + + const command = this.commandWrapper.getCommand() + + command.outputOption('-hls_time ' + segmentDuration) + command.outputOption('-hls_list_size ' + segmentListSize) + command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time') + command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) + command.outputOption('-master_pl_name ' + masterPlaylistName) + command.outputOption(`-f hls`) + + command.output(join(outPath, '%v.m3u8')) + } +} diff --git a/shared/ffmpeg/ffmpeg-utils.ts b/shared/ffmpeg/ffmpeg-utils.ts new file mode 100644 index 000000000..7d09c32ca --- /dev/null +++ b/shared/ffmpeg/ffmpeg-utils.ts @@ -0,0 +1,17 @@ +import { EncoderOptions } from '@shared/models' + +export type StreamType = 'audio' | 'video' + +export function buildStreamSuffix (base: string, streamNum?: number) { + if (streamNum !== undefined) { + return `${base}:${streamNum}` + } + + return base +} + +export function getScaleFilter (options: EncoderOptions): string { + if (options.scaleFilter) return options.scaleFilter.name + + return 'scale' +} diff --git a/shared/ffmpeg/ffmpeg-version.ts b/shared/ffmpeg/ffmpeg-version.ts new file mode 100644 index 000000000..41d9b2d89 --- /dev/null +++ b/shared/ffmpeg/ffmpeg-version.ts @@ -0,0 +1,24 @@ +import { exec } from 'child_process' +import ffmpeg from 'fluent-ffmpeg' + +export function getFFmpegVersion () { + return new Promise((res, rej) => { + (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => { + if (err) return rej(err) + if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path')) + + return exec(`${ffmpegPath} -version`, (err, stdout) => { + if (err) return rej(err) + + const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/) + if (!parsed?.[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`)) + + // Fix ffmpeg version that does not include patch version (4.4 for example) + let version = parsed[1] + if (version.match(/^\d+\.\d+$/)) { + version += '.0' + } + }) + }) + }) +} diff --git a/shared/ffmpeg/ffmpeg-vod.ts b/shared/ffmpeg/ffmpeg-vod.ts new file mode 100644 index 000000000..e40ca0a1e --- /dev/null +++ b/shared/ffmpeg/ffmpeg-vod.ts @@ -0,0 +1,256 @@ +import { MutexInterface } from 'async-mutex' +import { FfmpegCommand } from 'fluent-ffmpeg' +import { readFile, writeFile } from 'fs-extra' +import { dirname } from 'path' +import { pick } from '@shared/core-utils' +import { VideoResolution } from '@shared/models' +import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' +import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe' +import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets' + +export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' + +export interface BaseTranscodeVODOptions { + type: TranscodeVODOptionsType + + inputPath: string + outputPath: string + + // Will be released after the ffmpeg started + // To prevent a bug where the input file does not exist anymore when running ffmpeg + inputFileMutexReleaser: MutexInterface.Releaser + + resolution: number + fps: number +} + +export interface HLSTranscodeOptions extends BaseTranscodeVODOptions { + type: 'hls' + + copyCodecs: boolean + + hlsPlaylist: { + videoFilename: string + } +} + +export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { + type: 'hls-from-ts' + + isAAC: boolean + + hlsPlaylist: { + videoFilename: string + } +} + +export interface QuickTranscodeOptions extends BaseTranscodeVODOptions { + type: 'quick-transcode' +} + +export interface VideoTranscodeOptions extends BaseTranscodeVODOptions { + type: 'video' +} + +export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { + type: 'merge-audio' + audioPath: string +} + +export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { + type: 'only-audio' +} + +export type TranscodeVODOptions = + HLSTranscodeOptions + | HLSFromTSTranscodeOptions + | VideoTranscodeOptions + | MergeAudioTranscodeOptions + | OnlyAudioTranscodeOptions + | QuickTranscodeOptions + +// --------------------------------------------------------------------------- + +export class FFmpegVOD { + private readonly commandWrapper: FFmpegCommandWrapper + + private ended = false + + constructor (options: FFmpegCommandWrapperOptions) { + this.commandWrapper = new FFmpegCommandWrapper(options) + } + + async transcode (options: TranscodeVODOptions) { + const builders: { + [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise | void + } = { + 'quick-transcode': this.buildQuickTranscodeCommand.bind(this), + 'hls': this.buildHLSVODCommand.bind(this), + 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this), + 'merge-audio': this.buildAudioMergeCommand.bind(this), + // TODO: remove, we merge this in buildWebVideoCommand + 'only-audio': this.buildOnlyAudioCommand.bind(this), + 'video': this.buildWebVideoCommand.bind(this) + } + + this.commandWrapper.debugLog('Will run transcode.', { options }) + + const command = this.commandWrapper.buildCommand(options.inputPath) + .output(options.outputPath) + + await builders[options.type](options) + + command.on('start', () => { + setTimeout(() => { + options.inputFileMutexReleaser() + }, 1000) + }) + + await this.commandWrapper.runCommand() + + await this.fixHLSPlaylistIfNeeded(options) + + this.ended = true + } + + isEnded () { + return this.ended + } + + private async buildWebVideoCommand (options: TranscodeVODOptions) { + const { resolution, fps, inputPath } = options + + if (resolution === VideoResolution.H_NOVIDEO) { + presetOnlyAudio(this.commandWrapper) + return + } + + let scaleFilterValue: string + + if (resolution !== undefined) { + const probe = await ffprobePromise(inputPath) + const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe) + + scaleFilterValue = videoStreamInfo?.isPortraitMode === true + ? `w=${resolution}:h=-2` + : `w=-2:h=${resolution}` + } + + await presetVOD({ + commandWrapper: this.commandWrapper, + + resolution, + input: inputPath, + canCopyAudio: true, + canCopyVideo: true, + fps, + scaleFilterValue + }) + } + + private buildQuickTranscodeCommand (_options: TranscodeVODOptions) { + const command = this.commandWrapper.getCommand() + + presetCopy(this.commandWrapper) + + command.outputOption('-map_metadata -1') // strip all metadata + .outputOption('-movflags faststart') + } + + // --------------------------------------------------------------------------- + // Audio transcoding + // --------------------------------------------------------------------------- + + private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) { + const command = this.commandWrapper.getCommand() + + command.loop(undefined) + + await presetVOD({ + ...pick(options, [ 'resolution' ]), + + commandWrapper: this.commandWrapper, + input: options.audioPath, + canCopyAudio: true, + canCopyVideo: true, + fps: options.fps, + scaleFilterValue: this.getMergeAudioScaleFilterValue() + }) + + command.outputOption('-preset:v veryfast') + + command.input(options.audioPath) + .outputOption('-tune stillimage') + .outputOption('-shortest') + } + + private buildOnlyAudioCommand (_options: OnlyAudioTranscodeOptions) { + presetOnlyAudio(this.commandWrapper) + } + + // Avoid "height not divisible by 2" error + private getMergeAudioScaleFilterValue () { + return 'trunc(iw/2)*2:trunc(ih/2)*2' + } + + // --------------------------------------------------------------------------- + // HLS transcoding + // --------------------------------------------------------------------------- + + private async buildHLSVODCommand (options: HLSTranscodeOptions) { + const command = this.commandWrapper.getCommand() + + const videoPath = this.getHLSVideoPath(options) + + if (options.copyCodecs) presetCopy(this.commandWrapper) + else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper) + else await this.buildWebVideoCommand(options) + + this.addCommonHLSVODCommandOptions(command, videoPath) + } + + private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) { + const command = this.commandWrapper.getCommand() + + const videoPath = this.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') + } + + this.addCommonHLSVODCommandOptions(command, videoPath) + } + + private addCommonHLSVODCommandOptions (command: 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') + } + + private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) { + if (options.type !== 'hls' && options.type !== 'hls-from-ts') return + + const fileContent = await readFile(options.outputPath) + + const videoFileName = options.hlsPlaylist.videoFilename + const videoFilePath = this.getHLSVideoPath(options) + + // Fix wrong mapping with some ffmpeg versions + const newContent = fileContent.toString() + .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) + + await writeFile(options.outputPath, newContent) + } + + private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { + return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` + } +} diff --git a/shared/ffmpeg/ffprobe.ts b/shared/ffmpeg/ffprobe.ts new file mode 100644 index 000000000..fda08c28e --- /dev/null +++ b/shared/ffmpeg/ffprobe.ts @@ -0,0 +1,184 @@ +import { ffprobe, FfprobeData } from 'fluent-ffmpeg' +import { forceNumber } from '@shared/core-utils' +import { VideoResolution } from '@shared/models/videos' + +/** + * + * Helpers to run ffprobe and extract data from the JSON output + * + */ + +function ffprobePromise (path: string) { + return new Promise((res, rej) => { + ffprobe(path, (err, data) => { + if (err) return rej(err) + + return res(data) + }) + }) +} + +// --------------------------------------------------------------------------- +// Audio +// --------------------------------------------------------------------------- + +const imageCodecs = new Set([ + 'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2', + 'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff', + 'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd' +]) + +async function isAudioFile (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return true + + if (imageCodecs.has(videoStream.codec_name)) return true + + return false +} + +async function hasAudioStream (path: string, existingProbe?: FfprobeData) { + const { audioStream } = await getAudioStream(path, existingProbe) + + return !!audioStream +} + +async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) { + // 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 + const data = existingProbe || await ffprobePromise(videoPath) + + if (Array.isArray(data.streams)) { + const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') + + if (audioStream) { + return { + absolutePath: data.format.filename, + audioStream, + bitrate: forceNumber(audioStream['bit_rate']) + } + } + } + + return { absolutePath: data.format.filename } +} + +function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) { + const maxKBitrate = 384 + const kToBits = (kbits: number) => kbits * 1000 + + // If we did not manage to get the bitrate, use an average value + if (!bitrate) return 256 + + if (type === 'aac') { + switch (true) { + case bitrate > kToBits(maxKBitrate): + return maxKBitrate + + default: + return -1 // we interpret it as a signal to copy the audio stream as is + } + } + + /* + a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac. + That's why, when using aac, we can go to lower kbit/sec. The equivalences + made here are not made to be accurate, especially with good mp3 encoders. + */ + switch (true) { + case bitrate <= kToBits(192): + return 128 + + case bitrate <= kToBits(384): + return 256 + + default: + return maxKBitrate + } +} + +// --------------------------------------------------------------------------- +// Video +// --------------------------------------------------------------------------- + +async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) { + return { + width: 0, + height: 0, + ratio: 0, + resolution: VideoResolution.H_NOVIDEO, + isPortraitMode: false + } + } + + return { + width: videoStream.width, + height: videoStream.height, + ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width), + resolution: Math.min(videoStream.height, videoStream.width), + isPortraitMode: videoStream.height > videoStream.width + } +} + +async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) { + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return 0 + + for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { + const valuesText: string = videoStream[key] + if (!valuesText) continue + + const [ frames, seconds ] = valuesText.split('/') + if (!frames || !seconds) continue + + const result = parseInt(frames, 10) / parseInt(seconds, 10) + if (result > 0) return Math.round(result) + } + + return 0 +} + +async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise { + const metadata = existingProbe || await ffprobePromise(path) + + let bitrate = metadata.format.bit_rate + if (bitrate && !isNaN(bitrate)) return bitrate + + const videoStream = await getVideoStream(path, existingProbe) + if (!videoStream) return undefined + + bitrate = forceNumber(videoStream?.bit_rate) + if (bitrate && !isNaN(bitrate)) return bitrate + + return undefined +} + +async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { + const metadata = existingProbe || await ffprobePromise(path) + + return Math.round(metadata.format.duration) +} + +async function getVideoStream (path: string, existingProbe?: FfprobeData) { + const metadata = existingProbe || await ffprobePromise(path) + + return metadata.streams.find(s => s.codec_type === 'video') +} + +// --------------------------------------------------------------------------- + +export { + getVideoStreamDimensionsInfo, + getMaxAudioBitrate, + getVideoStream, + getVideoStreamDuration, + getAudioStream, + getVideoStreamFPS, + isAudioFile, + ffprobePromise, + getVideoStreamBitrate, + hasAudioStream +} diff --git a/shared/ffmpeg/index.ts b/shared/ffmpeg/index.ts new file mode 100644 index 000000000..07a7d5402 --- /dev/null +++ b/shared/ffmpeg/index.ts @@ -0,0 +1,8 @@ +export * from './ffmpeg-command-wrapper' +export * from './ffmpeg-edition' +export * from './ffmpeg-images' +export * from './ffmpeg-live' +export * from './ffmpeg-utils' +export * from './ffmpeg-version' +export * from './ffmpeg-vod' +export * from './ffprobe' diff --git a/shared/ffmpeg/shared/encoder-options.ts b/shared/ffmpeg/shared/encoder-options.ts new file mode 100644 index 000000000..9692a6b02 --- /dev/null +++ b/shared/ffmpeg/shared/encoder-options.ts @@ -0,0 +1,39 @@ +import { FfmpegCommand } from 'fluent-ffmpeg' +import { EncoderOptions } from '@shared/models' +import { buildStreamSuffix } from '../ffmpeg-utils' + +export function addDefaultEncoderGlobalParams (command: FfmpegCommand) { + // 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') + // allows import of source material with incompatible pixel formats (e.g. MJPEG video) + .outputOption('-pix_fmt yuv420p') +} + +export function addDefaultEncoderParams (options: { + command: FfmpegCommand + encoder: 'libx264' | string + fps: number + + streamNum?: 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)) + } + } +} + +export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) { + command.inputOptions(options.inputOptions ?? []) + .outputOptions(options.outputOptions ?? []) +} diff --git a/shared/ffmpeg/shared/index.ts b/shared/ffmpeg/shared/index.ts new file mode 100644 index 000000000..51de0316f --- /dev/null +++ b/shared/ffmpeg/shared/index.ts @@ -0,0 +1,2 @@ +export * from './encoder-options' +export * from './presets' diff --git a/shared/ffmpeg/shared/presets.ts b/shared/ffmpeg/shared/presets.ts new file mode 100644 index 000000000..dcebdc1cf --- /dev/null +++ b/shared/ffmpeg/shared/presets.ts @@ -0,0 +1,93 @@ +import { pick } from '@shared/core-utils' +import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper' +import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe' +import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options' +import { getScaleFilter, StreamType } from '../ffmpeg-utils' + +export async function presetVOD (options: { + commandWrapper: FFmpegCommandWrapper + + input: string + + canCopyAudio: boolean + canCopyVideo: boolean + + resolution: number + fps: number + + scaleFilterValue?: string +}) { + const { commandWrapper, input, resolution, fps, scaleFilterValue } = options + const command = commandWrapper.getCommand() + + command.format('mp4') + .outputOption('-movflags faststart') + + addDefaultEncoderGlobalParams(command) + + const probe = await ffprobePromise(input) + + // Audio encoder + const bitrate = await getVideoStreamBitrate(input, probe) + const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe) + + let streamsToProcess: StreamType[] = [ 'audio', 'video' ] + + if (!await hasAudioStream(input, probe)) { + command.noAudio() + streamsToProcess = [ 'video' ] + } + + for (const streamType of streamsToProcess) { + const builderResult = await commandWrapper.getEncoderBuilderResult({ + ...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]), + + input, + inputBitrate: bitrate, + inputRatio: videoStreamDimensions?.ratio || 0, + + resolution, + fps, + streamType, + + videoType: 'vod' as 'vod' + }) + + if (!builderResult) { + throw new Error('No available encoder found for stream ' + streamType) + } + + commandWrapper.debugLog( + `Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` + + `stream of input ${input} using ${commandWrapper.getProfile()} profile.`, + { builderResult, resolution, fps } + ) + + if (streamType === 'video') { + command.videoCodec(builderResult.encoder) + + if (scaleFilterValue) { + command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`) + } + } else if (streamType === 'audio') { + command.audioCodec(builderResult.encoder) + } + + applyEncoderOptions(command, builderResult.result) + addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps }) + } +} + +export function presetCopy (commandWrapper: FFmpegCommandWrapper) { + commandWrapper.getCommand() + .format('mp4') + .videoCodec('copy') + .audioCodec('copy') +} + +export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) { + commandWrapper.getCommand() + .format('mp4') + .audioCodec('copy') + .noVideo() +} -- cgit v1.2.3