aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-04-21 14:55:10 +0200
committerChocobozzz <chocobozzz@cpy.re>2023-05-09 08:57:34 +0200
commit0c9668f77901e7540e2c7045eb0f2974a4842a69 (patch)
tree226d3dd1565b0bb56588897af3b8530e6216e96b /shared
parent6bcb854cdea8688a32240bc5719c7d139806e00b (diff)
downloadPeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.tar.gz
PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.tar.zst
PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.zip
Implement remote runner jobs in server
Move ffmpeg functions to @shared
Diffstat (limited to 'shared')
-rw-r--r--shared/core-utils/common/number.ts12
-rw-r--r--shared/core-utils/common/promises.ts47
-rw-r--r--shared/extra-utils/index.ts1
-rw-r--r--shared/ffmpeg/ffmpeg-command-wrapper.ts234
-rw-r--r--shared/ffmpeg/ffmpeg-edition.ts239
-rw-r--r--shared/ffmpeg/ffmpeg-images.ts59
-rw-r--r--shared/ffmpeg/ffmpeg-live.ts184
-rw-r--r--shared/ffmpeg/ffmpeg-utils.ts17
-rw-r--r--shared/ffmpeg/ffmpeg-version.ts24
-rw-r--r--shared/ffmpeg/ffmpeg-vod.ts256
-rw-r--r--shared/ffmpeg/ffprobe.ts (renamed from shared/extra-utils/ffprobe.ts)19
-rw-r--r--shared/ffmpeg/index.ts8
-rw-r--r--shared/ffmpeg/shared/encoder-options.ts39
-rw-r--r--shared/ffmpeg/shared/index.ts2
-rw-r--r--shared/ffmpeg/shared/presets.ts93
-rw-r--r--shared/models/index.ts1
-rw-r--r--shared/models/runners/abort-runner-job-body.model.ts6
-rw-r--r--shared/models/runners/accept-runner-job-body.model.ts3
-rw-r--r--shared/models/runners/accept-runner-job-result.model.ts6
-rw-r--r--shared/models/runners/error-runner-job-body.model.ts6
-rw-r--r--shared/models/runners/index.ts21
-rw-r--r--shared/models/runners/list-runner-jobs-query.model.ts6
-rw-r--r--shared/models/runners/list-runner-registration-tokens.model.ts5
-rw-r--r--shared/models/runners/list-runners-query.model.ts5
-rw-r--r--shared/models/runners/register-runner-body.model.ts6
-rw-r--r--shared/models/runners/register-runner-result.model.ts4
-rw-r--r--shared/models/runners/request-runner-job-body.model.ts3
-rw-r--r--shared/models/runners/request-runner-job-result.model.ts10
-rw-r--r--shared/models/runners/runner-job-payload.model.ts68
-rw-r--r--shared/models/runners/runner-job-private-payload.model.ts34
-rw-r--r--shared/models/runners/runner-job-state.model.ts10
-rw-r--r--shared/models/runners/runner-job-success-body.model.ts41
-rw-r--r--shared/models/runners/runner-job-type.type.ts5
-rw-r--r--shared/models/runners/runner-job-update-body.model.ts28
-rw-r--r--shared/models/runners/runner-job.model.ts45
-rw-r--r--shared/models/runners/runner-registration-token.ts10
-rw-r--r--shared/models/runners/runner.model.ts12
-rw-r--r--shared/models/runners/unregister-runner-body.model.ts3
-rw-r--r--shared/models/server/custom-config.model.ts7
-rw-r--r--shared/models/server/job.model.ts43
-rw-r--r--shared/models/server/server-config.model.ts8
-rw-r--r--shared/models/server/server-error-code.enum.ts5
-rw-r--r--shared/models/users/user-right.enum.ts4
-rw-r--r--shared/models/videos/live/live-video-error.enum.ts4
44 files changed, 1608 insertions, 35 deletions
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 @@
1function forceNumber (value: any) { 1export function forceNumber (value: any) {
2 return parseInt(value + '') 2 return parseInt(value + '')
3} 3}
4 4
5export { 5export function isOdd (num: number) {
6 forceNumber 6 return (num % 2) !== 0
7}
8
9export function toEven (num: number) {
10 if (isOdd(num)) return num + 1
11
12 return num
7} 13}
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 @@
1function isPromise <T = unknown> (value: T | Promise<T>): value is Promise<T> { 1export function isPromise <T = unknown> (value: T | Promise<T>): value is Promise<T> {
2 return value && typeof (value as Promise<T>).then === 'function' 2 return value && typeof (value as Promise<T>).then === 'function'
3} 3}
4 4
5function isCatchable (value: any) { 5export function isCatchable (value: any) {
6 return value && typeof value.catch === 'function' 6 return value && typeof value.catch === 'function'
7} 7}
8 8
9function timeoutPromise <T> (promise: Promise<T>, timeoutMs: number) { 9export function timeoutPromise <T> (promise: Promise<T>, timeoutMs: number) {
10 let timer: ReturnType<typeof setTimeout> 10 let timer: ReturnType<typeof setTimeout>
11 11
12 return Promise.race([ 12 return Promise.race([
@@ -18,8 +18,41 @@ function timeoutPromise <T> (promise: Promise<T>, timeoutMs: number) {
18 ]).finally(() => clearTimeout(timer)) 18 ]).finally(() => clearTimeout(timer))
19} 19}
20 20
21export { 21export function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
22 isPromise, 22 return function promisified (): Promise<A> {
23 isCatchable, 23 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
24 timeoutPromise 24 // eslint-disable-next-line no-useless-call
25 func.apply(null, [ (err: any, res: A) => err ? reject(err) : resolve(res) ])
26 })
27 }
28}
29
30// Thanks to https://gist.github.com/kumasento/617daa7e46f13ecdd9b2
31export function promisify1<T, A> (func: (arg: T, cb: (err: any, result: A) => void) => void): (arg: T) => Promise<A> {
32 return function promisified (arg: T): Promise<A> {
33 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
34 // eslint-disable-next-line no-useless-call
35 func.apply(null, [ arg, (err: any, res: A) => err ? reject(err) : resolve(res) ])
36 })
37 }
38}
39
40// eslint-disable-next-line max-len
41export function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U) => Promise<A> {
42 return function promisified (arg1: T, arg2: U): Promise<A> {
43 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
44 // eslint-disable-next-line no-useless-call
45 func.apply(null, [ arg1, arg2, (err: any, res: A) => err ? reject(err) : resolve(res) ])
46 })
47 }
48}
49
50// eslint-disable-next-line max-len
51export function promisify3<T, U, V, A> (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise<A> {
52 return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
53 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
54 // eslint-disable-next-line no-useless-call
55 func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
56 })
57 }
25} 58}
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 @@
1export * from './crypto' 1export * from './crypto'
2export * from './ffprobe'
3export * from './file' 2export * from './file'
4export * from './uuid' 3export * 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 @@
1import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick, promisify0 } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4
5type FFmpegLogger = {
6 info: (msg: string, obj?: any) => void
7 debug: (msg: string, obj?: any) => void
8 warn: (msg: string, obj?: any) => void
9 error: (msg: string, obj?: any) => void
10}
11
12export interface FFmpegCommandWrapperOptions {
13 availableEncoders?: AvailableEncoders
14 profile?: string
15
16 niceness: number
17 tmpDirectory: string
18 threads: number
19
20 logger: FFmpegLogger
21 lTags?: { tags: string[] }
22
23 updateJobProgress?: (progress?: number) => void
24}
25
26export class FFmpegCommandWrapper {
27 private static supportedEncoders: Map<string, boolean>
28
29 private readonly availableEncoders: AvailableEncoders
30 private readonly profile: string
31
32 private readonly niceness: number
33 private readonly tmpDirectory: string
34 private readonly threads: number
35
36 private readonly logger: FFmpegLogger
37 private readonly lTags: { tags: string[] }
38
39 private readonly updateJobProgress: (progress?: number) => void
40
41 private command: FfmpegCommand
42
43 constructor (options: FFmpegCommandWrapperOptions) {
44 this.availableEncoders = options.availableEncoders
45 this.profile = options.profile
46 this.niceness = options.niceness
47 this.tmpDirectory = options.tmpDirectory
48 this.threads = options.threads
49 this.logger = options.logger
50 this.lTags = options.lTags || { tags: [] }
51 this.updateJobProgress = options.updateJobProgress
52 }
53
54 getAvailableEncoders () {
55 return this.availableEncoders
56 }
57
58 getProfile () {
59 return this.profile
60 }
61
62 getCommand () {
63 return this.command
64 }
65
66 // ---------------------------------------------------------------------------
67
68 debugLog (msg: string, meta: any) {
69 this.logger.debug(msg, { ...meta, ...this.lTags })
70 }
71
72 // ---------------------------------------------------------------------------
73
74 buildCommand (input: string) {
75 if (this.command) throw new Error('Command is already built')
76
77 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
78 this.command = ffmpeg(input, {
79 niceness: this.niceness,
80 cwd: this.tmpDirectory
81 })
82
83 if (this.threads > 0) {
84 // If we don't set any threads ffmpeg will chose automatically
85 this.command.outputOption('-threads ' + this.threads)
86 }
87
88 return this.command
89 }
90
91 async runCommand (options: {
92 silent?: boolean // false by default
93 } = {}) {
94 const { silent = false } = options
95
96 return new Promise<void>((res, rej) => {
97 let shellCommand: string
98
99 this.command.on('start', cmdline => { shellCommand = cmdline })
100
101 this.command.on('error', (err, stdout, stderr) => {
102 if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
103
104 rej(err)
105 })
106
107 this.command.on('end', (stdout, stderr) => {
108 this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
109
110 res()
111 })
112
113 if (this.updateJobProgress) {
114 this.command.on('progress', progress => {
115 if (!progress.percent) return
116
117 // Sometimes ffmpeg returns an invalid progress
118 let percent = Math.round(progress.percent)
119 if (percent < 0) percent = 0
120 if (percent > 100) percent = 100
121
122 this.updateJobProgress(percent)
123 })
124 }
125
126 this.command.run()
127 })
128 }
129
130 // ---------------------------------------------------------------------------
131
132 static resetSupportedEncoders () {
133 FFmpegCommandWrapper.supportedEncoders = undefined
134 }
135
136 // Run encoder builder depending on available encoders
137 // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
138 // If the default one does not exist, check the next encoder
139 async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
140 streamType: 'video' | 'audio'
141 input: string
142
143 videoType: 'vod' | 'live'
144 }) {
145 if (!this.availableEncoders) {
146 throw new Error('There is no available encoders')
147 }
148
149 const { streamType, videoType } = options
150
151 const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
152 const encoders = this.availableEncoders.available[videoType]
153
154 for (const encoder of encodersToTry) {
155 if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
156 this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
157 continue
158 }
159
160 if (!encoders[encoder]) {
161 this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
162 continue
163 }
164
165 // An object containing available profiles for this encoder
166 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
167 let builder = builderProfiles[this.profile]
168
169 if (!builder) {
170 this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
171 builder = builderProfiles.default
172
173 if (!builder) {
174 this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
175 continue
176 }
177 }
178
179 const result = await builder(
180 pick(options, [
181 'input',
182 'canCopyAudio',
183 'canCopyVideo',
184 'resolution',
185 'inputBitrate',
186 'fps',
187 'inputRatio',
188 'streamNum'
189 ])
190 )
191
192 return {
193 result,
194
195 // If we don't have output options, then copy the input stream
196 encoder: result.copy === true
197 ? 'copy'
198 : encoder
199 }
200 }
201
202 return null
203 }
204
205 // Detect supported encoders by ffmpeg
206 private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
207 if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
208 return FFmpegCommandWrapper.supportedEncoders
209 }
210
211 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
212 const availableFFmpegEncoders = await getAvailableEncodersPromise()
213
214 const searchEncoders = new Set<string>()
215 for (const type of [ 'live', 'vod' ]) {
216 for (const streamType of [ 'audio', 'video' ]) {
217 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
218 searchEncoders.add(encoder)
219 }
220 }
221 }
222
223 const supportedEncoders = new Map<string, boolean>()
224
225 for (const searchEncoder of searchEncoders) {
226 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
227 }
228
229 this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
230
231 FFmpegCommandWrapper.supportedEncoders = supportedEncoders
232 return supportedEncoders
233 }
234}
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 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
3import { presetVOD } from './shared/presets'
4import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe'
5
6export class FFmpegEdition {
7 private readonly commandWrapper: FFmpegCommandWrapper
8
9 constructor (options: FFmpegCommandWrapperOptions) {
10 this.commandWrapper = new FFmpegCommandWrapper(options)
11 }
12
13 async cutVideo (options: {
14 inputPath: string
15 outputPath: string
16 start?: number
17 end?: number
18 }) {
19 const { inputPath, outputPath } = options
20
21 const mainProbe = await ffprobePromise(inputPath)
22 const fps = await getVideoStreamFPS(inputPath, mainProbe)
23 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
24
25 const command = this.commandWrapper.buildCommand(inputPath)
26 .output(outputPath)
27
28 await presetVOD({
29 commandWrapper: this.commandWrapper,
30 input: inputPath,
31 resolution,
32 fps,
33 canCopyAudio: false,
34 canCopyVideo: false
35 })
36
37 if (options.start) {
38 command.outputOption('-ss ' + options.start)
39 }
40
41 if (options.end) {
42 command.outputOption('-to ' + options.end)
43 }
44
45 await this.commandWrapper.runCommand()
46 }
47
48 async addWatermark (options: {
49 inputPath: string
50 watermarkPath: string
51 outputPath: string
52
53 videoFilters: {
54 watermarkSizeRatio: number
55 horitonzalMarginRatio: number
56 verticalMarginRatio: number
57 }
58 }) {
59 const { watermarkPath, inputPath, outputPath, videoFilters } = options
60
61 const videoProbe = await ffprobePromise(inputPath)
62 const fps = await getVideoStreamFPS(inputPath, videoProbe)
63 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
64
65 const command = this.commandWrapper.buildCommand(inputPath)
66 .output(outputPath)
67
68 command.input(watermarkPath)
69
70 await presetVOD({
71 commandWrapper: this.commandWrapper,
72 input: inputPath,
73 resolution,
74 fps,
75 canCopyAudio: true,
76 canCopyVideo: false
77 })
78
79 const complexFilter: FilterSpecification[] = [
80 // Scale watermark
81 {
82 inputs: [ '[1]', '[0]' ],
83 filter: 'scale2ref',
84 options: {
85 w: 'oh*mdar',
86 h: `ih*${videoFilters.watermarkSizeRatio}`
87 },
88 outputs: [ '[watermark]', '[video]' ]
89 },
90
91 {
92 inputs: [ '[video]', '[watermark]' ],
93 filter: 'overlay',
94 options: {
95 x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`,
96 y: `main_h * ${videoFilters.verticalMarginRatio}`
97 }
98 }
99 ]
100
101 command.complexFilter(complexFilter)
102
103 await this.commandWrapper.runCommand()
104 }
105
106 async addIntroOutro (options: {
107 inputPath: string
108 introOutroPath: string
109 outputPath: string
110 type: 'intro' | 'outro'
111 }) {
112 const { introOutroPath, inputPath, outputPath, type } = options
113
114 const mainProbe = await ffprobePromise(inputPath)
115 const fps = await getVideoStreamFPS(inputPath, mainProbe)
116 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
117 const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
118
119 const introOutroProbe = await ffprobePromise(introOutroPath)
120 const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
121
122 const command = this.commandWrapper.buildCommand(inputPath)
123 .output(outputPath)
124
125 command.input(introOutroPath)
126
127 if (!introOutroHasAudio && mainHasAudio) {
128 const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
129
130 command.input('anullsrc')
131 command.withInputFormat('lavfi')
132 command.withInputOption('-t ' + duration)
133 }
134
135 await presetVOD({
136 commandWrapper: this.commandWrapper,
137 input: inputPath,
138 resolution,
139 fps,
140 canCopyAudio: false,
141 canCopyVideo: false
142 })
143
144 // Add black background to correctly scale intro/outro with padding
145 const complexFilter: FilterSpecification[] = [
146 {
147 inputs: [ '1', '0' ],
148 filter: 'scale2ref',
149 options: {
150 w: 'iw',
151 h: `ih`
152 },
153 outputs: [ 'intro-outro', 'main' ]
154 },
155 {
156 inputs: [ 'intro-outro', 'main' ],
157 filter: 'scale2ref',
158 options: {
159 w: 'iw',
160 h: `ih`
161 },
162 outputs: [ 'to-scale', 'main' ]
163 },
164 {
165 inputs: 'to-scale',
166 filter: 'drawbox',
167 options: {
168 t: 'fill'
169 },
170 outputs: [ 'to-scale-bg' ]
171 },
172 {
173 inputs: [ '1', 'to-scale-bg' ],
174 filter: 'scale2ref',
175 options: {
176 w: 'iw',
177 h: 'ih',
178 force_original_aspect_ratio: 'decrease',
179 flags: 'spline'
180 },
181 outputs: [ 'to-scale', 'to-scale-bg' ]
182 },
183 {
184 inputs: [ 'to-scale-bg', 'to-scale' ],
185 filter: 'overlay',
186 options: {
187 x: '(main_w - overlay_w)/2',
188 y: '(main_h - overlay_h)/2'
189 },
190 outputs: 'intro-outro-resized'
191 }
192 ]
193
194 const concatFilter = {
195 inputs: [],
196 filter: 'concat',
197 options: {
198 n: 2,
199 v: 1,
200 unsafe: 1
201 },
202 outputs: [ 'v' ]
203 }
204
205 const introOutroFilterInputs = [ 'intro-outro-resized' ]
206 const mainFilterInputs = [ 'main' ]
207
208 if (mainHasAudio) {
209 mainFilterInputs.push('0:a')
210
211 if (introOutroHasAudio) {
212 introOutroFilterInputs.push('1:a')
213 } else {
214 // Silent input
215 introOutroFilterInputs.push('2:a')
216 }
217 }
218
219 if (type === 'intro') {
220 concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
221 } else {
222 concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
223 }
224
225 if (mainHasAudio) {
226 concatFilter.options['a'] = 1
227 concatFilter.outputs.push('a')
228
229 command.outputOption('-map [a]')
230 }
231
232 command.outputOption('-map [v]')
233
234 complexFilter.push(concatFilter)
235 command.complexFilter(complexFilter)
236
237 await this.commandWrapper.runCommand()
238 }
239}
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 @@
1import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
2
3export class FFmpegImage {
4 private readonly commandWrapper: FFmpegCommandWrapper
5
6 constructor (options: FFmpegCommandWrapperOptions) {
7 this.commandWrapper = new FFmpegCommandWrapper(options)
8 }
9
10 convertWebPToJPG (options: {
11 path: string
12 destination: string
13 }): Promise<void> {
14 const { path, destination } = options
15
16 this.commandWrapper.buildCommand(path)
17 .output(destination)
18
19 return this.commandWrapper.runCommand({ silent: true })
20 }
21
22 processGIF (options: {
23 path: string
24 destination: string
25 newSize: { width: number, height: number }
26 }): Promise<void> {
27 const { path, destination, newSize } = options
28
29 this.commandWrapper.buildCommand(path)
30 .fps(20)
31 .size(`${newSize.width}x${newSize.height}`)
32 .output(destination)
33
34 return this.commandWrapper.runCommand()
35 }
36
37 async generateThumbnailFromVideo (options: {
38 fromPath: string
39 folder: string
40 imageName: string
41 }) {
42 const { fromPath, folder, imageName } = options
43
44 const pendingImageName = 'pending-' + imageName
45
46 const thumbnailOptions = {
47 filename: pendingImageName,
48 count: 1,
49 folder
50 }
51
52 return new Promise<string>((res, rej) => {
53 this.commandWrapper.buildCommand(fromPath)
54 .on('error', rej)
55 .on('end', () => res(imageName))
56 .thumbnail(thumbnailOptions)
57 })
58 }
59}
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 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { pick } from '@shared/core-utils'
4import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils'
6import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared'
7
8export class FFmpegLive {
9 private readonly commandWrapper: FFmpegCommandWrapper
10
11 constructor (options: FFmpegCommandWrapperOptions) {
12 this.commandWrapper = new FFmpegCommandWrapper(options)
13 }
14
15 async getLiveTranscodingCommand (options: {
16 inputUrl: string
17
18 outPath: string
19 masterPlaylistName: string
20
21 toTranscode: {
22 resolution: number
23 fps: number
24 }[]
25
26 // Input information
27 bitrate: number
28 ratio: number
29 hasAudio: boolean
30
31 segmentListSize: number
32 segmentDuration: number
33 }) {
34 const {
35 inputUrl,
36 outPath,
37 toTranscode,
38 bitrate,
39 masterPlaylistName,
40 ratio,
41 hasAudio
42 } = options
43 const command = this.commandWrapper.buildCommand(inputUrl)
44
45 const varStreamMap: string[] = []
46
47 const complexFilter: FilterSpecification[] = [
48 {
49 inputs: '[v:0]',
50 filter: 'split',
51 options: toTranscode.length,
52 outputs: toTranscode.map(t => `vtemp${t.resolution}`)
53 }
54 ]
55
56 command.outputOption('-sc_threshold 0')
57
58 addDefaultEncoderGlobalParams(command)
59
60 for (let i = 0; i < toTranscode.length; i++) {
61 const streamMap: string[] = []
62 const { resolution, fps } = toTranscode[i]
63
64 const baseEncoderBuilderParams = {
65 input: inputUrl,
66
67 canCopyAudio: true,
68 canCopyVideo: true,
69
70 inputBitrate: bitrate,
71 inputRatio: ratio,
72
73 resolution,
74 fps,
75
76 streamNum: i,
77 videoType: 'live' as 'live'
78 }
79
80 {
81 const streamType: StreamType = 'video'
82 const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
83 if (!builderResult) {
84 throw new Error('No available live video encoder found')
85 }
86
87 command.outputOption(`-map [vout${resolution}]`)
88
89 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
90
91 this.commandWrapper.debugLog(
92 `Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
93 { builderResult, fps, toTranscode }
94 )
95
96 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
97 applyEncoderOptions(command, builderResult.result)
98
99 complexFilter.push({
100 inputs: `vtemp${resolution}`,
101 filter: getScaleFilter(builderResult.result),
102 options: `w=-2:h=${resolution}`,
103 outputs: `vout${resolution}`
104 })
105
106 streamMap.push(`v:${i}`)
107 }
108
109 if (hasAudio) {
110 const streamType: StreamType = 'audio'
111 const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
112 if (!builderResult) {
113 throw new Error('No available live audio encoder found')
114 }
115
116 command.outputOption('-map a:0')
117
118 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
119
120 this.commandWrapper.debugLog(
121 `Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
122 { builderResult, fps, resolution }
123 )
124
125 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
126 applyEncoderOptions(command, builderResult.result)
127
128 streamMap.push(`a:${i}`)
129 }
130
131 varStreamMap.push(streamMap.join(','))
132 }
133
134 command.complexFilter(complexFilter)
135
136 this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
137
138 command.outputOption('-var_stream_map', varStreamMap.join(' '))
139
140 return command
141 }
142
143 getLiveMuxingCommand (options: {
144 inputUrl: string
145 outPath: string
146 masterPlaylistName: string
147
148 segmentListSize: number
149 segmentDuration: number
150 }) {
151 const { inputUrl, outPath, masterPlaylistName } = options
152
153 const command = this.commandWrapper.buildCommand(inputUrl)
154
155 command.outputOption('-c:v copy')
156 command.outputOption('-c:a copy')
157 command.outputOption('-map 0:a?')
158 command.outputOption('-map 0:v?')
159
160 this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
161
162 return command
163 }
164
165 private addDefaultLiveHLSParams (options: {
166 outPath: string
167 masterPlaylistName: string
168 segmentListSize: number
169 segmentDuration: number
170 }) {
171 const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options
172
173 const command = this.commandWrapper.getCommand()
174
175 command.outputOption('-hls_time ' + segmentDuration)
176 command.outputOption('-hls_list_size ' + segmentListSize)
177 command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time')
178 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
179 command.outputOption('-master_pl_name ' + masterPlaylistName)
180 command.outputOption(`-f hls`)
181
182 command.output(join(outPath, '%v.m3u8'))
183 }
184}
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 @@
1import { EncoderOptions } from '@shared/models'
2
3export type StreamType = 'audio' | 'video'
4
5export function buildStreamSuffix (base: string, streamNum?: number) {
6 if (streamNum !== undefined) {
7 return `${base}:${streamNum}`
8 }
9
10 return base
11}
12
13export function getScaleFilter (options: EncoderOptions): string {
14 if (options.scaleFilter) return options.scaleFilter.name
15
16 return 'scale'
17}
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 @@
1import { exec } from 'child_process'
2import ffmpeg from 'fluent-ffmpeg'
3
4export function getFFmpegVersion () {
5 return new Promise<string>((res, rej) => {
6 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
7 if (err) return rej(err)
8 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
9
10 return exec(`${ffmpegPath} -version`, (err, stdout) => {
11 if (err) return rej(err)
12
13 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
14 if (!parsed?.[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
15
16 // Fix ffmpeg version that does not include patch version (4.4 for example)
17 let version = parsed[1]
18 if (version.match(/^\d+\.\d+$/)) {
19 version += '.0'
20 }
21 })
22 })
23 })
24}
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 @@
1import { MutexInterface } from 'async-mutex'
2import { FfmpegCommand } from 'fluent-ffmpeg'
3import { readFile, writeFile } from 'fs-extra'
4import { dirname } from 'path'
5import { pick } from '@shared/core-utils'
6import { VideoResolution } from '@shared/models'
7import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
8import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe'
9import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets'
10
11export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
12
13export interface BaseTranscodeVODOptions {
14 type: TranscodeVODOptionsType
15
16 inputPath: string
17 outputPath: string
18
19 // Will be released after the ffmpeg started
20 // To prevent a bug where the input file does not exist anymore when running ffmpeg
21 inputFileMutexReleaser: MutexInterface.Releaser
22
23 resolution: number
24 fps: number
25}
26
27export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
28 type: 'hls'
29
30 copyCodecs: boolean
31
32 hlsPlaylist: {
33 videoFilename: string
34 }
35}
36
37export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
38 type: 'hls-from-ts'
39
40 isAAC: boolean
41
42 hlsPlaylist: {
43 videoFilename: string
44 }
45}
46
47export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
48 type: 'quick-transcode'
49}
50
51export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
52 type: 'video'
53}
54
55export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
56 type: 'merge-audio'
57 audioPath: string
58}
59
60export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
61 type: 'only-audio'
62}
63
64export type TranscodeVODOptions =
65 HLSTranscodeOptions
66 | HLSFromTSTranscodeOptions
67 | VideoTranscodeOptions
68 | MergeAudioTranscodeOptions
69 | OnlyAudioTranscodeOptions
70 | QuickTranscodeOptions
71
72// ---------------------------------------------------------------------------
73
74export class FFmpegVOD {
75 private readonly commandWrapper: FFmpegCommandWrapper
76
77 private ended = false
78
79 constructor (options: FFmpegCommandWrapperOptions) {
80 this.commandWrapper = new FFmpegCommandWrapper(options)
81 }
82
83 async transcode (options: TranscodeVODOptions) {
84 const builders: {
85 [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
86 } = {
87 'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
88 'hls': this.buildHLSVODCommand.bind(this),
89 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
90 'merge-audio': this.buildAudioMergeCommand.bind(this),
91 // TODO: remove, we merge this in buildWebVideoCommand
92 'only-audio': this.buildOnlyAudioCommand.bind(this),
93 'video': this.buildWebVideoCommand.bind(this)
94 }
95
96 this.commandWrapper.debugLog('Will run transcode.', { options })
97
98 const command = this.commandWrapper.buildCommand(options.inputPath)
99 .output(options.outputPath)
100
101 await builders[options.type](options)
102
103 command.on('start', () => {
104 setTimeout(() => {
105 options.inputFileMutexReleaser()
106 }, 1000)
107 })
108
109 await this.commandWrapper.runCommand()
110
111 await this.fixHLSPlaylistIfNeeded(options)
112
113 this.ended = true
114 }
115
116 isEnded () {
117 return this.ended
118 }
119
120 private async buildWebVideoCommand (options: TranscodeVODOptions) {
121 const { resolution, fps, inputPath } = options
122
123 if (resolution === VideoResolution.H_NOVIDEO) {
124 presetOnlyAudio(this.commandWrapper)
125 return
126 }
127
128 let scaleFilterValue: string
129
130 if (resolution !== undefined) {
131 const probe = await ffprobePromise(inputPath)
132 const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
133
134 scaleFilterValue = videoStreamInfo?.isPortraitMode === true
135 ? `w=${resolution}:h=-2`
136 : `w=-2:h=${resolution}`
137 }
138
139 await presetVOD({
140 commandWrapper: this.commandWrapper,
141
142 resolution,
143 input: inputPath,
144 canCopyAudio: true,
145 canCopyVideo: true,
146 fps,
147 scaleFilterValue
148 })
149 }
150
151 private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
152 const command = this.commandWrapper.getCommand()
153
154 presetCopy(this.commandWrapper)
155
156 command.outputOption('-map_metadata -1') // strip all metadata
157 .outputOption('-movflags faststart')
158 }
159
160 // ---------------------------------------------------------------------------
161 // Audio transcoding
162 // ---------------------------------------------------------------------------
163
164 private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
165 const command = this.commandWrapper.getCommand()
166
167 command.loop(undefined)
168
169 await presetVOD({
170 ...pick(options, [ 'resolution' ]),
171
172 commandWrapper: this.commandWrapper,
173 input: options.audioPath,
174 canCopyAudio: true,
175 canCopyVideo: true,
176 fps: options.fps,
177 scaleFilterValue: this.getMergeAudioScaleFilterValue()
178 })
179
180 command.outputOption('-preset:v veryfast')
181
182 command.input(options.audioPath)
183 .outputOption('-tune stillimage')
184 .outputOption('-shortest')
185 }
186
187 private buildOnlyAudioCommand (_options: OnlyAudioTranscodeOptions) {
188 presetOnlyAudio(this.commandWrapper)
189 }
190
191 // Avoid "height not divisible by 2" error
192 private getMergeAudioScaleFilterValue () {
193 return 'trunc(iw/2)*2:trunc(ih/2)*2'
194 }
195
196 // ---------------------------------------------------------------------------
197 // HLS transcoding
198 // ---------------------------------------------------------------------------
199
200 private async buildHLSVODCommand (options: HLSTranscodeOptions) {
201 const command = this.commandWrapper.getCommand()
202
203 const videoPath = this.getHLSVideoPath(options)
204
205 if (options.copyCodecs) presetCopy(this.commandWrapper)
206 else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper)
207 else await this.buildWebVideoCommand(options)
208
209 this.addCommonHLSVODCommandOptions(command, videoPath)
210 }
211
212 private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
213 const command = this.commandWrapper.getCommand()
214
215 const videoPath = this.getHLSVideoPath(options)
216
217 command.outputOption('-c copy')
218
219 if (options.isAAC) {
220 // Required for example when copying an AAC stream from an MPEG-TS
221 // Since it's a bitstream filter, we don't need to reencode the audio
222 command.outputOption('-bsf:a aac_adtstoasc')
223 }
224
225 this.addCommonHLSVODCommandOptions(command, videoPath)
226 }
227
228 private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
229 return command.outputOption('-hls_time 4')
230 .outputOption('-hls_list_size 0')
231 .outputOption('-hls_playlist_type vod')
232 .outputOption('-hls_segment_filename ' + outputPath)
233 .outputOption('-hls_segment_type fmp4')
234 .outputOption('-f hls')
235 .outputOption('-hls_flags single_file')
236 }
237
238 private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
239 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
240
241 const fileContent = await readFile(options.outputPath)
242
243 const videoFileName = options.hlsPlaylist.videoFilename
244 const videoFilePath = this.getHLSVideoPath(options)
245
246 // Fix wrong mapping with some ffmpeg versions
247 const newContent = fileContent.toString()
248 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
249
250 await writeFile(options.outputPath, newContent)
251 }
252
253 private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
254 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
255 }
256}
diff --git a/shared/extra-utils/ffprobe.ts b/shared/ffmpeg/ffprobe.ts
index 7efc58a0d..fda08c28e 100644
--- a/shared/extra-utils/ffprobe.ts
+++ b/shared/ffmpeg/ffprobe.ts
@@ -1,6 +1,6 @@
1import { ffprobe, FfprobeData } from 'fluent-ffmpeg' 1import { ffprobe, FfprobeData } from 'fluent-ffmpeg'
2import { forceNumber } from '@shared/core-utils' 2import { forceNumber } from '@shared/core-utils'
3import { VideoFileMetadata, VideoResolution } from '@shared/models/videos' 3import { VideoResolution } from '@shared/models/videos'
4 4
5/** 5/**
6 * 6 *
@@ -141,35 +141,29 @@ async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
141 return 0 141 return 0
142} 142}
143 143
144async function buildFileMetadata (path: string, existingProbe?: FfprobeData) {
145 const metadata = existingProbe || await ffprobePromise(path)
146
147 return new VideoFileMetadata(metadata)
148}
149
150async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> { 144async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
151 const metadata = await buildFileMetadata(path, existingProbe) 145 const metadata = existingProbe || await ffprobePromise(path)
152 146
153 let bitrate = metadata.format.bit_rate as number 147 let bitrate = metadata.format.bit_rate
154 if (bitrate && !isNaN(bitrate)) return bitrate 148 if (bitrate && !isNaN(bitrate)) return bitrate
155 149
156 const videoStream = await getVideoStream(path, existingProbe) 150 const videoStream = await getVideoStream(path, existingProbe)
157 if (!videoStream) return undefined 151 if (!videoStream) return undefined
158 152
159 bitrate = videoStream?.bit_rate 153 bitrate = forceNumber(videoStream?.bit_rate)
160 if (bitrate && !isNaN(bitrate)) return bitrate 154 if (bitrate && !isNaN(bitrate)) return bitrate
161 155
162 return undefined 156 return undefined
163} 157}
164 158
165async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { 159async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
166 const metadata = await buildFileMetadata(path, existingProbe) 160 const metadata = existingProbe || await ffprobePromise(path)
167 161
168 return Math.round(metadata.format.duration) 162 return Math.round(metadata.format.duration)
169} 163}
170 164
171async function getVideoStream (path: string, existingProbe?: FfprobeData) { 165async function getVideoStream (path: string, existingProbe?: FfprobeData) {
172 const metadata = await buildFileMetadata(path, existingProbe) 166 const metadata = existingProbe || await ffprobePromise(path)
173 167
174 return metadata.streams.find(s => s.codec_type === 'video') 168 return metadata.streams.find(s => s.codec_type === 'video')
175} 169}
@@ -178,7 +172,6 @@ async function getVideoStream (path: string, existingProbe?: FfprobeData) {
178 172
179export { 173export {
180 getVideoStreamDimensionsInfo, 174 getVideoStreamDimensionsInfo,
181 buildFileMetadata,
182 getMaxAudioBitrate, 175 getMaxAudioBitrate,
183 getVideoStream, 176 getVideoStream,
184 getVideoStreamDuration, 177 getVideoStreamDuration,
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 @@
1export * from './ffmpeg-command-wrapper'
2export * from './ffmpeg-edition'
3export * from './ffmpeg-images'
4export * from './ffmpeg-live'
5export * from './ffmpeg-utils'
6export * from './ffmpeg-version'
7export * from './ffmpeg-vod'
8export * 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 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { EncoderOptions } from '@shared/models'
3import { buildStreamSuffix } from '../ffmpeg-utils'
4
5export function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
6 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
7 command.outputOption('-max_muxing_queue_size 1024')
8 // strip all metadata
9 .outputOption('-map_metadata -1')
10 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
11 .outputOption('-pix_fmt yuv420p')
12}
13
14export function addDefaultEncoderParams (options: {
15 command: FfmpegCommand
16 encoder: 'libx264' | string
17 fps: number
18
19 streamNum?: number
20}) {
21 const { command, encoder, fps, streamNum } = options
22
23 if (encoder === 'libx264') {
24 // 3.1 is the minimal resource allocation for our highest supported resolution
25 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
26
27 if (fps) {
28 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
29 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
30 // https://superuser.com/a/908325
31 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
32 }
33 }
34}
35
36export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) {
37 command.inputOptions(options.inputOptions ?? [])
38 .outputOptions(options.outputOptions ?? [])
39}
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 @@
1export * from './encoder-options'
2export * 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 @@
1import { pick } from '@shared/core-utils'
2import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper'
3import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe'
4import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options'
5import { getScaleFilter, StreamType } from '../ffmpeg-utils'
6
7export async function presetVOD (options: {
8 commandWrapper: FFmpegCommandWrapper
9
10 input: string
11
12 canCopyAudio: boolean
13 canCopyVideo: boolean
14
15 resolution: number
16 fps: number
17
18 scaleFilterValue?: string
19}) {
20 const { commandWrapper, input, resolution, fps, scaleFilterValue } = options
21 const command = commandWrapper.getCommand()
22
23 command.format('mp4')
24 .outputOption('-movflags faststart')
25
26 addDefaultEncoderGlobalParams(command)
27
28 const probe = await ffprobePromise(input)
29
30 // Audio encoder
31 const bitrate = await getVideoStreamBitrate(input, probe)
32 const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
33
34 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
35
36 if (!await hasAudioStream(input, probe)) {
37 command.noAudio()
38 streamsToProcess = [ 'video' ]
39 }
40
41 for (const streamType of streamsToProcess) {
42 const builderResult = await commandWrapper.getEncoderBuilderResult({
43 ...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
44
45 input,
46 inputBitrate: bitrate,
47 inputRatio: videoStreamDimensions?.ratio || 0,
48
49 resolution,
50 fps,
51 streamType,
52
53 videoType: 'vod' as 'vod'
54 })
55
56 if (!builderResult) {
57 throw new Error('No available encoder found for stream ' + streamType)
58 }
59
60 commandWrapper.debugLog(
61 `Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` +
62 `stream of input ${input} using ${commandWrapper.getProfile()} profile.`,
63 { builderResult, resolution, fps }
64 )
65
66 if (streamType === 'video') {
67 command.videoCodec(builderResult.encoder)
68
69 if (scaleFilterValue) {
70 command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
71 }
72 } else if (streamType === 'audio') {
73 command.audioCodec(builderResult.encoder)
74 }
75
76 applyEncoderOptions(command, builderResult.result)
77 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps })
78 }
79}
80
81export function presetCopy (commandWrapper: FFmpegCommandWrapper) {
82 commandWrapper.getCommand()
83 .format('mp4')
84 .videoCodec('copy')
85 .audioCodec('copy')
86}
87
88export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) {
89 commandWrapper.getCommand()
90 .format('mp4')
91 .audioCodec('copy')
92 .noVideo()
93}
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'
11export * from './overviews' 11export * from './overviews'
12export * from './plugins' 12export * from './plugins'
13export * from './redundancy' 13export * from './redundancy'
14export * from './runners'
14export * from './search' 15export * from './search'
15export * from './server' 16export * from './server'
16export * from './tokens' 17export * 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 @@
1export interface AbortRunnerJobBody {
2 runnerToken: string
3 jobToken: string
4
5 reason: string
6}
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 @@
1export interface AcceptRunnerJobBody {
2 runnerToken: string
3}
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 @@
1import { RunnerJobPayload } from './runner-job-payload.model'
2import { RunnerJob } from './runner-job.model'
3
4export interface AcceptRunnerJobResult <T extends RunnerJobPayload = RunnerJobPayload> {
5 job: RunnerJob<T> & { jobToken: string }
6}
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 @@
1export interface ErrorRunnerJobBody {
2 runnerToken: string
3 jobToken: string
4
5 message: string
6}
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 @@
1export * from './abort-runner-job-body.model'
2export * from './accept-runner-job-body.model'
3export * from './accept-runner-job-result.model'
4export * from './error-runner-job-body.model'
5export * from './list-runner-jobs-query.model'
6export * from './list-runner-registration-tokens.model'
7export * from './list-runners-query.model'
8export * from './register-runner-body.model'
9export * from './register-runner-result.model'
10export * from './request-runner-job-body.model'
11export * from './request-runner-job-result.model'
12export * from './runner-job-payload.model'
13export * from './runner-job-private-payload.model'
14export * from './runner-job-state.model'
15export * from './runner-job-success-body.model'
16export * from './runner-job-type.type'
17export * from './runner-job-update-body.model'
18export * from './runner-job.model'
19export * from './runner-registration-token'
20export * from './runner.model'
21export * 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 @@
1export interface ListRunnerJobsQuery {
2 start?: number
3 count?: number
4 sort?: string
5 search?: string
6}
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 @@
1export interface ListRunnerRegistrationTokensQuery {
2 start?: number
3 count?: number
4 sort?: string
5}
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 @@
1export interface ListRunnersQuery {
2 start?: number
3 count?: number
4 sort?: string
5}
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 @@
1export interface RegisterRunnerBody {
2 registrationToken: string
3
4 name: string
5 description?: string
6}
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 @@
1export interface RegisterRunnerResult {
2 id: number
3 runnerToken: string
4}
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 @@
1export interface RequestRunnerJobBody {
2 runnerToken: string
3}
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 @@
1import { RunnerJobPayload } from './runner-job-payload.model'
2import { RunnerJobType } from './runner-job-type.type'
3
4export interface RequestRunnerJobResult <P extends RunnerJobPayload = RunnerJobPayload> {
5 availableJobs: {
6 uuid: string
7 type: RunnerJobType
8 payload: P
9 }[]
10}
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 @@
1export type RunnerJobVODPayload =
2 RunnerJobVODWebVideoTranscodingPayload |
3 RunnerJobVODHLSTranscodingPayload |
4 RunnerJobVODAudioMergeTranscodingPayload
5
6export type RunnerJobPayload =
7 RunnerJobVODPayload |
8 RunnerJobLiveRTMPHLSTranscodingPayload
9
10// ---------------------------------------------------------------------------
11
12export interface RunnerJobVODWebVideoTranscodingPayload {
13 input: {
14 videoFileUrl: string
15 }
16
17 output: {
18 resolution: number
19 fps: number
20 }
21}
22
23export interface RunnerJobVODHLSTranscodingPayload {
24 input: {
25 videoFileUrl: string
26 }
27
28 output: {
29 resolution: number
30 fps: number
31 }
32}
33
34export interface RunnerJobVODAudioMergeTranscodingPayload {
35 input: {
36 audioFileUrl: string
37 previewFileUrl: string
38 }
39
40 output: {
41 resolution: number
42 fps: number
43 }
44}
45
46// ---------------------------------------------------------------------------
47
48export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload {
49 return !!(payload as RunnerJobVODAudioMergeTranscodingPayload).input.audioFileUrl
50}
51
52// ---------------------------------------------------------------------------
53
54export interface RunnerJobLiveRTMPHLSTranscodingPayload {
55 input: {
56 rtmpUrl: string
57 }
58
59 output: {
60 toTranscode: {
61 resolution: number
62 fps: number
63 }[]
64
65 segmentDuration: number
66 segmentListSize: number
67 }
68}
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 @@
1export type RunnerJobVODPrivatePayload =
2 RunnerJobVODWebVideoTranscodingPrivatePayload |
3 RunnerJobVODAudioMergeTranscodingPrivatePayload |
4 RunnerJobVODHLSTranscodingPrivatePayload
5
6export type RunnerJobPrivatePayload =
7 RunnerJobVODPrivatePayload |
8 RunnerJobLiveRTMPHLSTranscodingPrivatePayload
9
10// ---------------------------------------------------------------------------
11
12export interface RunnerJobVODWebVideoTranscodingPrivatePayload {
13 videoUUID: string
14 isNewVideo: boolean
15}
16
17export interface RunnerJobVODAudioMergeTranscodingPrivatePayload {
18 videoUUID: string
19 isNewVideo: boolean
20}
21
22export interface RunnerJobVODHLSTranscodingPrivatePayload {
23 videoUUID: string
24 isNewVideo: boolean
25 deleteWebVideoFiles: boolean
26}
27
28// ---------------------------------------------------------------------------
29
30export interface RunnerJobLiveRTMPHLSTranscodingPrivatePayload {
31 videoUUID: string
32 masterPlaylistName: string
33 outputDirectory: string
34}
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 @@
1export enum RunnerJobState {
2 PENDING = 1,
3 PROCESSING = 2,
4 COMPLETED = 3,
5 ERRORED = 4,
6 WAITING_FOR_PARENT_JOB = 5,
7 CANCELLED = 6,
8 PARENT_ERRORED = 7,
9 PARENT_CANCELLED = 8
10}
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 @@
1export interface RunnerJobSuccessBody {
2 runnerToken: string
3 jobToken: string
4
5 payload: RunnerJobSuccessPayload
6}
7
8// ---------------------------------------------------------------------------
9
10export type RunnerJobSuccessPayload =
11 VODWebVideoTranscodingSuccess |
12 VODHLSTranscodingSuccess |
13 VODAudioMergeTranscodingSuccess |
14 LiveRTMPHLSTranscodingSuccess
15
16export interface VODWebVideoTranscodingSuccess {
17 videoFile: Blob | string
18}
19
20export interface VODHLSTranscodingSuccess {
21 videoFile: Blob | string
22 resolutionPlaylistFile: Blob | string
23}
24
25export interface VODAudioMergeTranscodingSuccess {
26 videoFile: Blob | string
27}
28
29export interface LiveRTMPHLSTranscodingSuccess {
30
31}
32
33export function isWebVideoOrAudioMergeTranscodingPayloadSuccess (
34 payload: RunnerJobSuccessPayload
35): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess {
36 return !!(payload as VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess)?.videoFile
37}
38
39export function isHLSTranscodingPayloadSuccess (payload: RunnerJobSuccessPayload): payload is VODHLSTranscodingSuccess {
40 return !!(payload as VODHLSTranscodingSuccess)?.resolutionPlaylistFile
41}
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 @@
1export type RunnerJobType =
2 'vod-web-video-transcoding' |
3 'vod-hls-transcoding' |
4 'vod-audio-merge-transcoding' |
5 '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 @@
1export interface RunnerJobUpdateBody {
2 runnerToken: string
3 jobToken: string
4
5 progress?: number
6 payload?: RunnerJobUpdatePayload
7}
8
9// ---------------------------------------------------------------------------
10
11export type RunnerJobUpdatePayload = LiveRTMPHLSTranscodingUpdatePayload
12
13export interface LiveRTMPHLSTranscodingUpdatePayload {
14 type: 'add-chunk' | 'remove-chunk'
15
16 masterPlaylistFile?: Blob | string
17
18 resolutionPlaylistFilename?: string
19 resolutionPlaylistFile?: Blob | string
20
21 videoChunkFilename: string
22 videoChunkFile?: Blob | string
23}
24
25export function isLiveRTMPHLSTranscodingUpdatePayload (value: RunnerJobUpdatePayload): value is LiveRTMPHLSTranscodingUpdatePayload {
26 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
27 return !!(value as LiveRTMPHLSTranscodingUpdatePayload)?.videoChunkFilename
28}
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 @@
1import { VideoConstant } from '../videos'
2import { RunnerJobPayload } from './runner-job-payload.model'
3import { RunnerJobPrivatePayload } from './runner-job-private-payload.model'
4import { RunnerJobState } from './runner-job-state.model'
5import { RunnerJobType } from './runner-job-type.type'
6
7export interface RunnerJob <T extends RunnerJobPayload = RunnerJobPayload> {
8 uuid: string
9
10 type: RunnerJobType
11
12 state: VideoConstant<RunnerJobState>
13
14 payload: T
15
16 failures: number
17 error: string | null
18
19 progress: number
20 priority: number
21
22 startedAt: Date | string
23 createdAt: Date | string
24 updatedAt: Date | string
25 finishedAt: Date | string
26
27 parent?: {
28 type: RunnerJobType
29 state: VideoConstant<RunnerJobState>
30 uuid: string
31 }
32
33 // If associated to a runner
34 runner?: {
35 id: number
36 name: string
37
38 description: string
39 }
40}
41
42// eslint-disable-next-line max-len
43export interface RunnerJobAdmin <T extends RunnerJobPayload = RunnerJobPayload, U extends RunnerJobPrivatePayload = RunnerJobPrivatePayload> extends RunnerJob<T> {
44 privatePayload: U
45}
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 @@
1export interface RunnerRegistrationToken {
2 id: number
3
4 registrationToken: string
5
6 createdAt: Date
7 updatedAt: Date
8
9 registeredRunnersCount: number
10}
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 @@
1export interface Runner {
2 id: number
3
4 name: string
5 description: string
6
7 ip: string
8 lastContact: Date | string
9
10 createdAt: Date | string
11 updatedAt: Date | string
12}
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 @@
1export interface UnregisterRunnerBody {
2 runnerToken: string
3}
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 {
116 allowAdditionalExtensions: boolean 116 allowAdditionalExtensions: boolean
117 allowAudioFiles: boolean 117 allowAudioFiles: boolean
118 118
119 remoteRunners: {
120 enabled: boolean
121 }
122
119 threads: number 123 threads: number
120 concurrency: number 124 concurrency: number
121 125
@@ -149,6 +153,9 @@ export interface CustomConfig {
149 153
150 transcoding: { 154 transcoding: {
151 enabled: boolean 155 enabled: boolean
156 remoteRunners: {
157 enabled: boolean
158 }
152 threads: number 159 threads: number
153 profile: string 160 profile: string
154 resolutions: ConfigResolutions 161 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 =
18 | 'after-video-channel-import' 18 | 'after-video-channel-import'
19 | 'email' 19 | 'email'
20 | 'federate-video' 20 | 'federate-video'
21 | 'transcoding-job-builder'
21 | 'manage-video-torrent' 22 | 'manage-video-torrent'
22 | 'move-to-object-storage' 23 | 'move-to-object-storage'
23 | 'notify' 24 | 'notify'
@@ -41,6 +42,10 @@ export interface Job {
41 createdAt: Date | string 42 createdAt: Date | string
42 finishedOn: Date | string 43 finishedOn: Date | string
43 processedOn: Date | string 44 processedOn: Date | string
45
46 parent?: {
47 id: string
48 }
44} 49}
45 50
46export type ActivitypubHttpBroadcastPayload = { 51export type ActivitypubHttpBroadcastPayload = {
@@ -139,30 +144,28 @@ interface BaseTranscodingPayload {
139export interface HLSTranscodingPayload extends BaseTranscodingPayload { 144export interface HLSTranscodingPayload extends BaseTranscodingPayload {
140 type: 'new-resolution-to-hls' 145 type: 'new-resolution-to-hls'
141 resolution: VideoResolution 146 resolution: VideoResolution
147 fps: number
142 copyCodecs: boolean 148 copyCodecs: boolean
143 149
144 hasAudio: boolean 150 deleteWebTorrentFiles: boolean
145
146 autoDeleteWebTorrentIfNeeded: boolean
147 isMaxQuality: boolean
148} 151}
149 152
150export interface NewWebTorrentResolutionTranscodingPayload extends BaseTranscodingPayload { 153export interface NewWebTorrentResolutionTranscodingPayload extends BaseTranscodingPayload {
151 type: 'new-resolution-to-webtorrent' 154 type: 'new-resolution-to-webtorrent'
152 resolution: VideoResolution 155 resolution: VideoResolution
153 156 fps: number
154 hasAudio: boolean
155 createHLSIfNeeded: boolean
156} 157}
157 158
158export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { 159export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
159 type: 'merge-audio-to-webtorrent' 160 type: 'merge-audio-to-webtorrent'
160 resolution: VideoResolution 161 resolution: VideoResolution
161 createHLSIfNeeded: true 162 fps: number
162} 163}
163 164
164export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { 165export interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
165 type: 'optimize-to-webtorrent' 166 type: 'optimize-to-webtorrent'
167
168 quickTranscode: boolean
166} 169}
167 170
168export type VideoTranscodingPayload = 171export type VideoTranscodingPayload =
@@ -258,3 +261,27 @@ export interface FederateVideoPayload {
258 videoUUID: string 261 videoUUID: string
259 isNewVideo: boolean 262 isNewVideo: boolean
260} 263}
264
265// ---------------------------------------------------------------------------
266
267export interface TranscodingJobBuilderPayload {
268 videoUUID: string
269
270 optimizeJob?: {
271 isNewVideo: boolean
272 }
273
274 // Array of jobs to create
275 jobs?: {
276 type: 'video-transcoding'
277 payload: VideoTranscodingPayload
278 priority?: number
279 }[]
280
281 // Array of sequential jobs to create
282 sequentialJobs?: {
283 type: 'video-transcoding'
284 payload: VideoTranscodingPayload
285 priority?: number
286 }[][]
287}
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 {
148 148
149 profile: string 149 profile: string
150 availableProfiles: string[] 150 availableProfiles: string[]
151
152 remoteRunners: {
153 enabled: boolean
154 }
151 } 155 }
152 156
153 live: { 157 live: {
@@ -165,6 +169,10 @@ export interface ServerConfig {
165 transcoding: { 169 transcoding: {
166 enabled: boolean 170 enabled: boolean
167 171
172 remoteRunners: {
173 enabled: boolean
174 }
175
168 enabledResolutions: number[] 176 enabledResolutions: number[]
169 177
170 profile: string 178 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 {
45 INVALID_TWO_FACTOR = 'invalid_two_factor', 45 INVALID_TWO_FACTOR = 'invalid_two_factor',
46 46
47 ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval', 47 ACCOUNT_WAITING_FOR_APPROVAL = 'account_waiting_for_approval',
48 ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected' 48 ACCOUNT_APPROVAL_REJECTED = 'account_approval_rejected',
49
50 RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state',
51 UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token'
49} 52}
50 53
51/** 54/**
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 {
45 45
46 MANAGE_VIDEO_IMPORTS = 27, 46 MANAGE_VIDEO_IMPORTS = 27,
47 47
48 MANAGE_REGISTRATIONS = 28 48 MANAGE_REGISTRATIONS = 28,
49
50 MANAGE_RUNNERS = 29
49} 51}
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 {
3 DURATION_EXCEEDED = 2, 3 DURATION_EXCEEDED = 2,
4 QUOTA_EXCEEDED = 3, 4 QUOTA_EXCEEDED = 3,
5 FFMPEG_ERROR = 4, 5 FFMPEG_ERROR = 4,
6 BLACKLISTED = 5 6 BLACKLISTED = 5,
7 RUNNER_JOB_ERROR = 6,
8 RUNNER_JOB_CANCEL = 7
7} 9}