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/core-utils/common/number.ts | 12 +- shared/core-utils/common/promises.ts | 47 +++- shared/extra-utils/ffprobe.ts | 191 --------------- shared/extra-utils/index.ts | 1 - 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 ++++++++ shared/models/index.ts | 1 + .../models/runners/abort-runner-job-body.model.ts | 6 + .../models/runners/accept-runner-job-body.model.ts | 3 + .../runners/accept-runner-job-result.model.ts | 6 + .../models/runners/error-runner-job-body.model.ts | 6 + shared/models/runners/index.ts | 21 ++ .../models/runners/list-runner-jobs-query.model.ts | 6 + .../list-runner-registration-tokens.model.ts | 5 + shared/models/runners/list-runners-query.model.ts | 5 + .../models/runners/register-runner-body.model.ts | 6 + .../models/runners/register-runner-result.model.ts | 4 + .../runners/request-runner-job-body.model.ts | 3 + .../runners/request-runner-job-result.model.ts | 10 + shared/models/runners/runner-job-payload.model.ts | 68 ++++++ .../runners/runner-job-private-payload.model.ts | 34 +++ shared/models/runners/runner-job-state.model.ts | 10 + .../runners/runner-job-success-body.model.ts | 41 ++++ shared/models/runners/runner-job-type.type.ts | 5 + .../models/runners/runner-job-update-body.model.ts | 28 +++ shared/models/runners/runner-job.model.ts | 45 ++++ shared/models/runners/runner-registration-token.ts | 10 + shared/models/runners/runner.model.ts | 12 + .../models/runners/unregister-runner-body.model.ts | 3 + shared/models/server/custom-config.model.ts | 7 + shared/models/server/job.model.ts | 43 +++- shared/models/server/server-config.model.ts | 8 + shared/models/server/server-error-code.enum.ts | 5 +- shared/models/users/user-right.enum.ts | 4 +- shared/models/videos/live/live-video-error.enum.ts | 4 +- 45 files changed, 1786 insertions(+), 213 deletions(-) delete mode 100644 shared/extra-utils/ffprobe.ts 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 create mode 100644 shared/models/runners/abort-runner-job-body.model.ts create mode 100644 shared/models/runners/accept-runner-job-body.model.ts create mode 100644 shared/models/runners/accept-runner-job-result.model.ts create mode 100644 shared/models/runners/error-runner-job-body.model.ts create mode 100644 shared/models/runners/index.ts create mode 100644 shared/models/runners/list-runner-jobs-query.model.ts create mode 100644 shared/models/runners/list-runner-registration-tokens.model.ts create mode 100644 shared/models/runners/list-runners-query.model.ts create mode 100644 shared/models/runners/register-runner-body.model.ts create mode 100644 shared/models/runners/register-runner-result.model.ts create mode 100644 shared/models/runners/request-runner-job-body.model.ts create mode 100644 shared/models/runners/request-runner-job-result.model.ts create mode 100644 shared/models/runners/runner-job-payload.model.ts create mode 100644 shared/models/runners/runner-job-private-payload.model.ts create mode 100644 shared/models/runners/runner-job-state.model.ts create mode 100644 shared/models/runners/runner-job-success-body.model.ts create mode 100644 shared/models/runners/runner-job-type.type.ts create mode 100644 shared/models/runners/runner-job-update-body.model.ts create mode 100644 shared/models/runners/runner-job.model.ts create mode 100644 shared/models/runners/runner-registration-token.ts create mode 100644 shared/models/runners/runner.model.ts create mode 100644 shared/models/runners/unregister-runner-body.model.ts (limited to 'shared') diff --git a/shared/core-utils/common/number.ts b/shared/core-utils/common/number.ts index 9a96dcf5c..ce5a6041a 100644 --- a/shared/core-utils/common/number.ts +++ b/shared/core-utils/common/number.ts @@ -1,7 +1,13 @@ -function forceNumber (value: any) { +export function forceNumber (value: any) { return parseInt(value + '') } -export { - forceNumber +export function isOdd (num: number) { + return (num % 2) !== 0 +} + +export function toEven (num: number) { + if (isOdd(num)) return num + 1 + + return num } diff --git a/shared/core-utils/common/promises.ts b/shared/core-utils/common/promises.ts index f17221b97..e3792d12e 100644 --- a/shared/core-utils/common/promises.ts +++ b/shared/core-utils/common/promises.ts @@ -1,12 +1,12 @@ -function isPromise (value: T | Promise): value is Promise { +export function isPromise (value: T | Promise): value is Promise { return value && typeof (value as Promise).then === 'function' } -function isCatchable (value: any) { +export function isCatchable (value: any) { return value && typeof value.catch === 'function' } -function timeoutPromise (promise: Promise, timeoutMs: number) { +export function timeoutPromise (promise: Promise, timeoutMs: number) { let timer: ReturnType return Promise.race([ @@ -18,8 +18,41 @@ function timeoutPromise (promise: Promise, timeoutMs: number) { ]).finally(() => clearTimeout(timer)) } -export { - isPromise, - isCatchable, - timeoutPromise +export function promisify0 (func: (cb: (err: any, result: A) => void) => void): () => Promise { + return function promisified (): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} + +// Thanks to https://gist.github.com/kumasento/617daa7e46f13ecdd9b2 +export function promisify1 (func: (arg: T, cb: (err: any, result: A) => void) => void): (arg: T) => Promise { + return function promisified (arg: T): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ arg, (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} + +// eslint-disable-next-line max-len +export function promisify2 (func: (arg1: T, arg2: U, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U) => Promise { + return function promisified (arg1: T, arg2: U): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ arg1, arg2, (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } +} + +// eslint-disable-next-line max-len +export function promisify3 (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise { + return function promisified (arg1: T, arg2: U, arg3: V): Promise { + return new Promise((resolve: (arg: A) => void, reject: (err: any) => void) => { + // eslint-disable-next-line no-useless-call + func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ]) + }) + } } diff --git a/shared/extra-utils/ffprobe.ts b/shared/extra-utils/ffprobe.ts deleted file mode 100644 index 7efc58a0d..000000000 --- a/shared/extra-utils/ffprobe.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { ffprobe, FfprobeData } from 'fluent-ffmpeg' -import { forceNumber } from '@shared/core-utils' -import { VideoFileMetadata, 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 buildFileMetadata (path: string, existingProbe?: FfprobeData) { - const metadata = existingProbe || await ffprobePromise(path) - - return new VideoFileMetadata(metadata) -} - -async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise { - const metadata = await buildFileMetadata(path, existingProbe) - - let bitrate = metadata.format.bit_rate as number - if (bitrate && !isNaN(bitrate)) return bitrate - - const videoStream = await getVideoStream(path, existingProbe) - if (!videoStream) return undefined - - bitrate = videoStream?.bit_rate - if (bitrate && !isNaN(bitrate)) return bitrate - - return undefined -} - -async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { - const metadata = await buildFileMetadata(path, existingProbe) - - return Math.round(metadata.format.duration) -} - -async function getVideoStream (path: string, existingProbe?: FfprobeData) { - const metadata = await buildFileMetadata(path, existingProbe) - - return metadata.streams.find(s => s.codec_type === 'video') -} - -// --------------------------------------------------------------------------- - -export { - getVideoStreamDimensionsInfo, - buildFileMetadata, - getMaxAudioBitrate, - getVideoStream, - getVideoStreamDuration, - getAudioStream, - getVideoStreamFPS, - isAudioFile, - ffprobePromise, - getVideoStreamBitrate, - hasAudioStream -} diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts index e2e161a7b..d4cfcbec8 100644 --- a/shared/extra-utils/index.ts +++ b/shared/extra-utils/index.ts @@ -1,4 +1,3 @@ export * from './crypto' -export * from './ffprobe' export * from './file' export * from './uuid' 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() +} diff --git a/shared/models/index.ts b/shared/models/index.ts index 439e9c8e1..78f6e73e3 100644 --- a/shared/models/index.ts +++ b/shared/models/index.ts @@ -11,6 +11,7 @@ export * from './moderation' export * from './overviews' export * from './plugins' export * from './redundancy' +export * from './runners' export * from './search' export * from './server' export * from './tokens' diff --git a/shared/models/runners/abort-runner-job-body.model.ts b/shared/models/runners/abort-runner-job-body.model.ts new file mode 100644 index 000000000..0b9c46c91 --- /dev/null +++ b/shared/models/runners/abort-runner-job-body.model.ts @@ -0,0 +1,6 @@ +export interface AbortRunnerJobBody { + runnerToken: string + jobToken: string + + reason: string +} diff --git a/shared/models/runners/accept-runner-job-body.model.ts b/shared/models/runners/accept-runner-job-body.model.ts new file mode 100644 index 000000000..cb266c4e6 --- /dev/null +++ b/shared/models/runners/accept-runner-job-body.model.ts @@ -0,0 +1,3 @@ +export interface AcceptRunnerJobBody { + runnerToken: string +} diff --git a/shared/models/runners/accept-runner-job-result.model.ts b/shared/models/runners/accept-runner-job-result.model.ts new file mode 100644 index 000000000..f2094b945 --- /dev/null +++ b/shared/models/runners/accept-runner-job-result.model.ts @@ -0,0 +1,6 @@ +import { RunnerJobPayload } from './runner-job-payload.model' +import { RunnerJob } from './runner-job.model' + +export interface AcceptRunnerJobResult { + job: RunnerJob & { jobToken: string } +} diff --git a/shared/models/runners/error-runner-job-body.model.ts b/shared/models/runners/error-runner-job-body.model.ts new file mode 100644 index 000000000..ac8568409 --- /dev/null +++ b/shared/models/runners/error-runner-job-body.model.ts @@ -0,0 +1,6 @@ +export interface ErrorRunnerJobBody { + runnerToken: string + jobToken: string + + message: string +} diff --git a/shared/models/runners/index.ts b/shared/models/runners/index.ts new file mode 100644 index 000000000..a52b82d2e --- /dev/null +++ b/shared/models/runners/index.ts @@ -0,0 +1,21 @@ +export * from './abort-runner-job-body.model' +export * from './accept-runner-job-body.model' +export * from './accept-runner-job-result.model' +export * from './error-runner-job-body.model' +export * from './list-runner-jobs-query.model' +export * from './list-runner-registration-tokens.model' +export * from './list-runners-query.model' +export * from './register-runner-body.model' +export * from './register-runner-result.model' +export * from './request-runner-job-body.model' +export * from './request-runner-job-result.model' +export * from './runner-job-payload.model' +export * from './runner-job-private-payload.model' +export * from './runner-job-state.model' +export * from './runner-job-success-body.model' +export * from './runner-job-type.type' +export * from './runner-job-update-body.model' +export * from './runner-job.model' +export * from './runner-registration-token' +export * from './runner.model' +export * from './unregister-runner-body.model' diff --git a/shared/models/runners/list-runner-jobs-query.model.ts b/shared/models/runners/list-runner-jobs-query.model.ts new file mode 100644 index 000000000..a5b62c55d --- /dev/null +++ b/shared/models/runners/list-runner-jobs-query.model.ts @@ -0,0 +1,6 @@ +export interface ListRunnerJobsQuery { + start?: number + count?: number + sort?: string + search?: string +} diff --git a/shared/models/runners/list-runner-registration-tokens.model.ts b/shared/models/runners/list-runner-registration-tokens.model.ts new file mode 100644 index 000000000..872e059cf --- /dev/null +++ b/shared/models/runners/list-runner-registration-tokens.model.ts @@ -0,0 +1,5 @@ +export interface ListRunnerRegistrationTokensQuery { + start?: number + count?: number + sort?: string +} diff --git a/shared/models/runners/list-runners-query.model.ts b/shared/models/runners/list-runners-query.model.ts new file mode 100644 index 000000000..d4362e4c5 --- /dev/null +++ b/shared/models/runners/list-runners-query.model.ts @@ -0,0 +1,5 @@ +export interface ListRunnersQuery { + start?: number + count?: number + sort?: string +} diff --git a/shared/models/runners/register-runner-body.model.ts b/shared/models/runners/register-runner-body.model.ts new file mode 100644 index 000000000..969bb35e1 --- /dev/null +++ b/shared/models/runners/register-runner-body.model.ts @@ -0,0 +1,6 @@ +export interface RegisterRunnerBody { + registrationToken: string + + name: string + description?: string +} diff --git a/shared/models/runners/register-runner-result.model.ts b/shared/models/runners/register-runner-result.model.ts new file mode 100644 index 000000000..e31776c6a --- /dev/null +++ b/shared/models/runners/register-runner-result.model.ts @@ -0,0 +1,4 @@ +export interface RegisterRunnerResult { + id: number + runnerToken: string +} diff --git a/shared/models/runners/request-runner-job-body.model.ts b/shared/models/runners/request-runner-job-body.model.ts new file mode 100644 index 000000000..0970d9007 --- /dev/null +++ b/shared/models/runners/request-runner-job-body.model.ts @@ -0,0 +1,3 @@ +export interface RequestRunnerJobBody { + runnerToken: string +} diff --git a/shared/models/runners/request-runner-job-result.model.ts b/shared/models/runners/request-runner-job-result.model.ts new file mode 100644 index 000000000..98601c42c --- /dev/null +++ b/shared/models/runners/request-runner-job-result.model.ts @@ -0,0 +1,10 @@ +import { RunnerJobPayload } from './runner-job-payload.model' +import { RunnerJobType } from './runner-job-type.type' + +export interface RequestRunnerJobResult

{ + availableJobs: { + uuid: string + type: RunnerJobType + payload: P + }[] +} diff --git a/shared/models/runners/runner-job-payload.model.ts b/shared/models/runners/runner-job-payload.model.ts new file mode 100644 index 000000000..8f0c17135 --- /dev/null +++ b/shared/models/runners/runner-job-payload.model.ts @@ -0,0 +1,68 @@ +export type RunnerJobVODPayload = + RunnerJobVODWebVideoTranscodingPayload | + RunnerJobVODHLSTranscodingPayload | + RunnerJobVODAudioMergeTranscodingPayload + +export type RunnerJobPayload = + RunnerJobVODPayload | + RunnerJobLiveRTMPHLSTranscodingPayload + +// --------------------------------------------------------------------------- + +export interface RunnerJobVODWebVideoTranscodingPayload { + input: { + videoFileUrl: string + } + + output: { + resolution: number + fps: number + } +} + +export interface RunnerJobVODHLSTranscodingPayload { + input: { + videoFileUrl: string + } + + output: { + resolution: number + fps: number + } +} + +export interface RunnerJobVODAudioMergeTranscodingPayload { + input: { + audioFileUrl: string + previewFileUrl: string + } + + output: { + resolution: number + fps: number + } +} + +// --------------------------------------------------------------------------- + +export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload { + return !!(payload as RunnerJobVODAudioMergeTranscodingPayload).input.audioFileUrl +} + +// --------------------------------------------------------------------------- + +export interface RunnerJobLiveRTMPHLSTranscodingPayload { + input: { + rtmpUrl: string + } + + output: { + toTranscode: { + resolution: number + fps: number + }[] + + segmentDuration: number + segmentListSize: number + } +} diff --git a/shared/models/runners/runner-job-private-payload.model.ts b/shared/models/runners/runner-job-private-payload.model.ts new file mode 100644 index 000000000..c1d8d1045 --- /dev/null +++ b/shared/models/runners/runner-job-private-payload.model.ts @@ -0,0 +1,34 @@ +export type RunnerJobVODPrivatePayload = + RunnerJobVODWebVideoTranscodingPrivatePayload | + RunnerJobVODAudioMergeTranscodingPrivatePayload | + RunnerJobVODHLSTranscodingPrivatePayload + +export type RunnerJobPrivatePayload = + RunnerJobVODPrivatePayload | + RunnerJobLiveRTMPHLSTranscodingPrivatePayload + +// --------------------------------------------------------------------------- + +export interface RunnerJobVODWebVideoTranscodingPrivatePayload { + videoUUID: string + isNewVideo: boolean +} + +export interface RunnerJobVODAudioMergeTranscodingPrivatePayload { + videoUUID: string + isNewVideo: boolean +} + +export interface RunnerJobVODHLSTranscodingPrivatePayload { + videoUUID: string + isNewVideo: boolean + deleteWebVideoFiles: boolean +} + +// --------------------------------------------------------------------------- + +export interface RunnerJobLiveRTMPHLSTranscodingPrivatePayload { + videoUUID: string + masterPlaylistName: string + outputDirectory: string +} diff --git a/shared/models/runners/runner-job-state.model.ts b/shared/models/runners/runner-job-state.model.ts new file mode 100644 index 000000000..738db38b7 --- /dev/null +++ b/shared/models/runners/runner-job-state.model.ts @@ -0,0 +1,10 @@ +export enum RunnerJobState { + PENDING = 1, + PROCESSING = 2, + COMPLETED = 3, + ERRORED = 4, + WAITING_FOR_PARENT_JOB = 5, + CANCELLED = 6, + PARENT_ERRORED = 7, + PARENT_CANCELLED = 8 +} diff --git a/shared/models/runners/runner-job-success-body.model.ts b/shared/models/runners/runner-job-success-body.model.ts new file mode 100644 index 000000000..223b7552d --- /dev/null +++ b/shared/models/runners/runner-job-success-body.model.ts @@ -0,0 +1,41 @@ +export interface RunnerJobSuccessBody { + runnerToken: string + jobToken: string + + payload: RunnerJobSuccessPayload +} + +// --------------------------------------------------------------------------- + +export type RunnerJobSuccessPayload = + VODWebVideoTranscodingSuccess | + VODHLSTranscodingSuccess | + VODAudioMergeTranscodingSuccess | + LiveRTMPHLSTranscodingSuccess + +export interface VODWebVideoTranscodingSuccess { + videoFile: Blob | string +} + +export interface VODHLSTranscodingSuccess { + videoFile: Blob | string + resolutionPlaylistFile: Blob | string +} + +export interface VODAudioMergeTranscodingSuccess { + videoFile: Blob | string +} + +export interface LiveRTMPHLSTranscodingSuccess { + +} + +export function isWebVideoOrAudioMergeTranscodingPayloadSuccess ( + payload: RunnerJobSuccessPayload +): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess { + return !!(payload as VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess)?.videoFile +} + +export function isHLSTranscodingPayloadSuccess (payload: RunnerJobSuccessPayload): payload is VODHLSTranscodingSuccess { + return !!(payload as VODHLSTranscodingSuccess)?.resolutionPlaylistFile +} diff --git a/shared/models/runners/runner-job-type.type.ts b/shared/models/runners/runner-job-type.type.ts new file mode 100644 index 000000000..36d3b9b25 --- /dev/null +++ b/shared/models/runners/runner-job-type.type.ts @@ -0,0 +1,5 @@ +export type RunnerJobType = + 'vod-web-video-transcoding' | + 'vod-hls-transcoding' | + 'vod-audio-merge-transcoding' | + 'live-rtmp-hls-transcoding' diff --git a/shared/models/runners/runner-job-update-body.model.ts b/shared/models/runners/runner-job-update-body.model.ts new file mode 100644 index 000000000..ed94bbe63 --- /dev/null +++ b/shared/models/runners/runner-job-update-body.model.ts @@ -0,0 +1,28 @@ +export interface RunnerJobUpdateBody { + runnerToken: string + jobToken: string + + progress?: number + payload?: RunnerJobUpdatePayload +} + +// --------------------------------------------------------------------------- + +export type RunnerJobUpdatePayload = LiveRTMPHLSTranscodingUpdatePayload + +export interface LiveRTMPHLSTranscodingUpdatePayload { + type: 'add-chunk' | 'remove-chunk' + + masterPlaylistFile?: Blob | string + + resolutionPlaylistFilename?: string + resolutionPlaylistFile?: Blob | string + + videoChunkFilename: string + videoChunkFile?: Blob | string +} + +export function isLiveRTMPHLSTranscodingUpdatePayload (value: RunnerJobUpdatePayload): value is LiveRTMPHLSTranscodingUpdatePayload { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return !!(value as LiveRTMPHLSTranscodingUpdatePayload)?.videoChunkFilename +} diff --git a/shared/models/runners/runner-job.model.ts b/shared/models/runners/runner-job.model.ts new file mode 100644 index 000000000..080093563 --- /dev/null +++ b/shared/models/runners/runner-job.model.ts @@ -0,0 +1,45 @@ +import { VideoConstant } from '../videos' +import { RunnerJobPayload } from './runner-job-payload.model' +import { RunnerJobPrivatePayload } from './runner-job-private-payload.model' +import { RunnerJobState } from './runner-job-state.model' +import { RunnerJobType } from './runner-job-type.type' + +export interface RunnerJob { + uuid: string + + type: RunnerJobType + + state: VideoConstant + + payload: T + + failures: number + error: string | null + + progress: number + priority: number + + startedAt: Date | string + createdAt: Date | string + updatedAt: Date | string + finishedAt: Date | string + + parent?: { + type: RunnerJobType + state: VideoConstant + uuid: string + } + + // If associated to a runner + runner?: { + id: number + name: string + + description: string + } +} + +// eslint-disable-next-line max-len +export interface RunnerJobAdmin extends RunnerJob { + privatePayload: U +} diff --git a/shared/models/runners/runner-registration-token.ts b/shared/models/runners/runner-registration-token.ts new file mode 100644 index 000000000..0a157aa51 --- /dev/null +++ b/shared/models/runners/runner-registration-token.ts @@ -0,0 +1,10 @@ +export interface RunnerRegistrationToken { + id: number + + registrationToken: string + + createdAt: Date + updatedAt: Date + + registeredRunnersCount: number +} diff --git a/shared/models/runners/runner.model.ts b/shared/models/runners/runner.model.ts new file mode 100644 index 000000000..3284f2992 --- /dev/null +++ b/shared/models/runners/runner.model.ts @@ -0,0 +1,12 @@ +export interface Runner { + id: number + + name: string + description: string + + ip: string + lastContact: Date | string + + createdAt: Date | string + updatedAt: Date | string +} diff --git a/shared/models/runners/unregister-runner-body.model.ts b/shared/models/runners/unregister-runner-body.model.ts new file mode 100644 index 000000000..d3465c5d6 --- /dev/null +++ b/shared/models/runners/unregister-runner-body.model.ts @@ -0,0 +1,3 @@ +export interface UnregisterRunnerBody { + runnerToken: string +} diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 6ffe3a676..5d2c10278 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -116,6 +116,10 @@ export interface CustomConfig { allowAdditionalExtensions: boolean allowAudioFiles: boolean + remoteRunners: { + enabled: boolean + } + threads: number concurrency: number @@ -149,6 +153,9 @@ export interface CustomConfig { transcoding: { enabled: boolean + remoteRunners: { + enabled: boolean + } threads: number profile: string resolutions: ConfigResolutions diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 9c0b5ea56..16187d133 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts @@ -18,6 +18,7 @@ export type JobType = | 'after-video-channel-import' | 'email' | 'federate-video' + | 'transcoding-job-builder' | 'manage-video-torrent' | 'move-to-object-storage' | 'notify' @@ -41,6 +42,10 @@ export interface Job { createdAt: Date | string finishedOn: Date | string processedOn: Date | string + + parent?: { + id: string + } } export type ActivitypubHttpBroadcastPayload = { @@ -139,30 +144,28 @@ interface BaseTranscodingPayload { export interface HLSTranscodingPayload extends BaseTranscodingPayload { type: 'new-resolution-to-hls' resolution: VideoResolution + fps: number copyCodecs: boolean - hasAudio: boolean - - autoDeleteWebTorrentIfNeeded: boolean - isMaxQuality: boolean + deleteWebTorrentFiles: boolean } export interface NewWebTorrentResolutionTranscodingPayload extends BaseTranscodingPayload { type: 'new-resolution-to-webtorrent' resolution: VideoResolution - - hasAudio: boolean - createHLSIfNeeded: boolean + fps: number } export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { type: 'merge-audio-to-webtorrent' resolution: VideoResolution - createHLSIfNeeded: true + fps: number } export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { type: 'optimize-to-webtorrent' + + quickTranscode: boolean } export type VideoTranscodingPayload = @@ -258,3 +261,27 @@ export interface FederateVideoPayload { videoUUID: string isNewVideo: boolean } + +// --------------------------------------------------------------------------- + +export interface TranscodingJobBuilderPayload { + videoUUID: string + + optimizeJob?: { + isNewVideo: boolean + } + + // Array of jobs to create + jobs?: { + type: 'video-transcoding' + payload: VideoTranscodingPayload + priority?: number + }[] + + // Array of sequential jobs to create + sequentialJobs?: { + type: 'video-transcoding' + payload: VideoTranscodingPayload + priority?: number + }[][] +} diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index d0bd9a00f..38b9d0385 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -148,6 +148,10 @@ export interface ServerConfig { profile: string availableProfiles: string[] + + remoteRunners: { + enabled: boolean + } } live: { @@ -165,6 +169,10 @@ export interface ServerConfig { transcoding: { enabled: boolean + remoteRunners: { + enabled: boolean + } + enabledResolutions: number[] profile: string diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts index a39cde1b3..24d3c6d21 100644 --- a/shared/models/server/server-error-code.enum.ts +++ b/shared/models/server/server-error-code.enum.ts @@ -45,7 +45,10 @@ export const enum ServerErrorCode { INVALID_TWO_FACTOR = 'invalid_two_factor', ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval', - ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected' + ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected', + + RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state', + UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token' } /** diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 42e5c8cd6..a5a770b75 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -45,5 +45,7 @@ export const enum UserRight { MANAGE_VIDEO_IMPORTS = 27, - MANAGE_REGISTRATIONS = 28 + MANAGE_REGISTRATIONS = 28, + + MANAGE_RUNNERS = 29 } diff --git a/shared/models/videos/live/live-video-error.enum.ts b/shared/models/videos/live/live-video-error.enum.ts index 3a8e4afa0..a26453505 100644 --- a/shared/models/videos/live/live-video-error.enum.ts +++ b/shared/models/videos/live/live-video-error.enum.ts @@ -3,5 +3,7 @@ export const enum LiveVideoError { DURATION_EXCEEDED = 2, QUOTA_EXCEEDED = 3, FFMPEG_ERROR = 4, - BLACKLISTED = 5 + BLACKLISTED = 5, + RUNNER_JOB_ERROR = 6, + RUNNER_JOB_CANCEL = 7 } -- cgit v1.2.3