diff options
Diffstat (limited to 'shared')
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 @@ | |||
1 | function forceNumber (value: any) { | 1 | export function forceNumber (value: any) { |
2 | return parseInt(value + '') | 2 | return parseInt(value + '') |
3 | } | 3 | } |
4 | 4 | ||
5 | export { | 5 | export function isOdd (num: number) { |
6 | forceNumber | 6 | return (num % 2) !== 0 |
7 | } | ||
8 | |||
9 | export 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 @@ | |||
1 | function isPromise <T = unknown> (value: T | Promise<T>): value is Promise<T> { | 1 | export 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 | ||
5 | function isCatchable (value: any) { | 5 | export function isCatchable (value: any) { |
6 | return value && typeof value.catch === 'function' | 6 | return value && typeof value.catch === 'function' |
7 | } | 7 | } |
8 | 8 | ||
9 | function timeoutPromise <T> (promise: Promise<T>, timeoutMs: number) { | 9 | export 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 | ||
21 | export { | 21 | export 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 | ||
31 | export 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 | ||
41 | export 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 | ||
51 | export 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 @@ | |||
1 | export * from './crypto' | 1 | export * from './crypto' |
2 | export * from './ffprobe' | ||
3 | export * from './file' | 2 | export * from './file' |
4 | export * from './uuid' | 3 | 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 @@ | |||
1 | import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg' | ||
2 | import { pick, promisify0 } from '@shared/core-utils' | ||
3 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models' | ||
4 | |||
5 | type 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 | |||
12 | export 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 | |||
26 | export 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 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' | ||
3 | import { presetVOD } from './shared/presets' | ||
4 | import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe' | ||
5 | |||
6 | export 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 @@ | |||
1 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' | ||
2 | |||
3 | export 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 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { join } from 'path' | ||
3 | import { pick } from '@shared/core-utils' | ||
4 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' | ||
5 | import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils' | ||
6 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared' | ||
7 | |||
8 | export 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 @@ | |||
1 | import { EncoderOptions } from '@shared/models' | ||
2 | |||
3 | export type StreamType = 'audio' | 'video' | ||
4 | |||
5 | export function buildStreamSuffix (base: string, streamNum?: number) { | ||
6 | if (streamNum !== undefined) { | ||
7 | return `${base}:${streamNum}` | ||
8 | } | ||
9 | |||
10 | return base | ||
11 | } | ||
12 | |||
13 | export 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 @@ | |||
1 | import { exec } from 'child_process' | ||
2 | import ffmpeg from 'fluent-ffmpeg' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { MutexInterface } from 'async-mutex' | ||
2 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { readFile, writeFile } from 'fs-extra' | ||
4 | import { dirname } from 'path' | ||
5 | import { pick } from '@shared/core-utils' | ||
6 | import { VideoResolution } from '@shared/models' | ||
7 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' | ||
8 | import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe' | ||
9 | import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets' | ||
10 | |||
11 | export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
12 | |||
13 | export 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 | |||
27 | export interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | ||
28 | type: 'hls' | ||
29 | |||
30 | copyCodecs: boolean | ||
31 | |||
32 | hlsPlaylist: { | ||
33 | videoFilename: string | ||
34 | } | ||
35 | } | ||
36 | |||
37 | export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | ||
38 | type: 'hls-from-ts' | ||
39 | |||
40 | isAAC: boolean | ||
41 | |||
42 | hlsPlaylist: { | ||
43 | videoFilename: string | ||
44 | } | ||
45 | } | ||
46 | |||
47 | export interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | ||
48 | type: 'quick-transcode' | ||
49 | } | ||
50 | |||
51 | export interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | ||
52 | type: 'video' | ||
53 | } | ||
54 | |||
55 | export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
56 | type: 'merge-audio' | ||
57 | audioPath: string | ||
58 | } | ||
59 | |||
60 | export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
61 | type: 'only-audio' | ||
62 | } | ||
63 | |||
64 | export type TranscodeVODOptions = | ||
65 | HLSTranscodeOptions | ||
66 | | HLSFromTSTranscodeOptions | ||
67 | | VideoTranscodeOptions | ||
68 | | MergeAudioTranscodeOptions | ||
69 | | OnlyAudioTranscodeOptions | ||
70 | | QuickTranscodeOptions | ||
71 | |||
72 | // --------------------------------------------------------------------------- | ||
73 | |||
74 | export 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 @@ | |||
1 | import { ffprobe, FfprobeData } from 'fluent-ffmpeg' | 1 | import { ffprobe, FfprobeData } from 'fluent-ffmpeg' |
2 | import { forceNumber } from '@shared/core-utils' | 2 | import { forceNumber } from '@shared/core-utils' |
3 | import { VideoFileMetadata, VideoResolution } from '@shared/models/videos' | 3 | import { 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 | ||
144 | async function buildFileMetadata (path: string, existingProbe?: FfprobeData) { | ||
145 | const metadata = existingProbe || await ffprobePromise(path) | ||
146 | |||
147 | return new VideoFileMetadata(metadata) | ||
148 | } | ||
149 | |||
150 | async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> { | 144 | async 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 | ||
165 | async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { | 159 | async 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 | ||
171 | async function getVideoStream (path: string, existingProbe?: FfprobeData) { | 165 | async 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 | ||
179 | export { | 173 | export { |
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 @@ | |||
1 | export * from './ffmpeg-command-wrapper' | ||
2 | export * from './ffmpeg-edition' | ||
3 | export * from './ffmpeg-images' | ||
4 | export * from './ffmpeg-live' | ||
5 | export * from './ffmpeg-utils' | ||
6 | export * from './ffmpeg-version' | ||
7 | export * from './ffmpeg-vod' | ||
8 | 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 @@ | |||
1 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { EncoderOptions } from '@shared/models' | ||
3 | import { buildStreamSuffix } from '../ffmpeg-utils' | ||
4 | |||
5 | export 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 | |||
14 | export 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 | |||
36 | export 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 @@ | |||
1 | export * from './encoder-options' | ||
2 | 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 @@ | |||
1 | import { pick } from '@shared/core-utils' | ||
2 | import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper' | ||
3 | import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe' | ||
4 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options' | ||
5 | import { getScaleFilter, StreamType } from '../ffmpeg-utils' | ||
6 | |||
7 | export 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 | |||
81 | export function presetCopy (commandWrapper: FFmpegCommandWrapper) { | ||
82 | commandWrapper.getCommand() | ||
83 | .format('mp4') | ||
84 | .videoCodec('copy') | ||
85 | .audioCodec('copy') | ||
86 | } | ||
87 | |||
88 | export 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' | |||
11 | export * from './overviews' | 11 | export * from './overviews' |
12 | export * from './plugins' | 12 | export * from './plugins' |
13 | export * from './redundancy' | 13 | export * from './redundancy' |
14 | export * from './runners' | ||
14 | export * from './search' | 15 | export * from './search' |
15 | export * from './server' | 16 | export * from './server' |
16 | export * from './tokens' | 17 | 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import { RunnerJobPayload } from './runner-job-payload.model' | ||
2 | import { RunnerJob } from './runner-job.model' | ||
3 | |||
4 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export * from './abort-runner-job-body.model' | ||
2 | export * from './accept-runner-job-body.model' | ||
3 | export * from './accept-runner-job-result.model' | ||
4 | export * from './error-runner-job-body.model' | ||
5 | export * from './list-runner-jobs-query.model' | ||
6 | export * from './list-runner-registration-tokens.model' | ||
7 | export * from './list-runners-query.model' | ||
8 | export * from './register-runner-body.model' | ||
9 | export * from './register-runner-result.model' | ||
10 | export * from './request-runner-job-body.model' | ||
11 | export * from './request-runner-job-result.model' | ||
12 | export * from './runner-job-payload.model' | ||
13 | export * from './runner-job-private-payload.model' | ||
14 | export * from './runner-job-state.model' | ||
15 | export * from './runner-job-success-body.model' | ||
16 | export * from './runner-job-type.type' | ||
17 | export * from './runner-job-update-body.model' | ||
18 | export * from './runner-job.model' | ||
19 | export * from './runner-registration-token' | ||
20 | export * from './runner.model' | ||
21 | 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | import { RunnerJobPayload } from './runner-job-payload.model' | ||
2 | import { RunnerJobType } from './runner-job-type.type' | ||
3 | |||
4 | export 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 @@ | |||
1 | export type RunnerJobVODPayload = | ||
2 | RunnerJobVODWebVideoTranscodingPayload | | ||
3 | RunnerJobVODHLSTranscodingPayload | | ||
4 | RunnerJobVODAudioMergeTranscodingPayload | ||
5 | |||
6 | export type RunnerJobPayload = | ||
7 | RunnerJobVODPayload | | ||
8 | RunnerJobLiveRTMPHLSTranscodingPayload | ||
9 | |||
10 | // --------------------------------------------------------------------------- | ||
11 | |||
12 | export interface RunnerJobVODWebVideoTranscodingPayload { | ||
13 | input: { | ||
14 | videoFileUrl: string | ||
15 | } | ||
16 | |||
17 | output: { | ||
18 | resolution: number | ||
19 | fps: number | ||
20 | } | ||
21 | } | ||
22 | |||
23 | export interface RunnerJobVODHLSTranscodingPayload { | ||
24 | input: { | ||
25 | videoFileUrl: string | ||
26 | } | ||
27 | |||
28 | output: { | ||
29 | resolution: number | ||
30 | fps: number | ||
31 | } | ||
32 | } | ||
33 | |||
34 | export 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 | |||
48 | export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload { | ||
49 | return !!(payload as RunnerJobVODAudioMergeTranscodingPayload).input.audioFileUrl | ||
50 | } | ||
51 | |||
52 | // --------------------------------------------------------------------------- | ||
53 | |||
54 | export 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 @@ | |||
1 | export type RunnerJobVODPrivatePayload = | ||
2 | RunnerJobVODWebVideoTranscodingPrivatePayload | | ||
3 | RunnerJobVODAudioMergeTranscodingPrivatePayload | | ||
4 | RunnerJobVODHLSTranscodingPrivatePayload | ||
5 | |||
6 | export type RunnerJobPrivatePayload = | ||
7 | RunnerJobVODPrivatePayload | | ||
8 | RunnerJobLiveRTMPHLSTranscodingPrivatePayload | ||
9 | |||
10 | // --------------------------------------------------------------------------- | ||
11 | |||
12 | export interface RunnerJobVODWebVideoTranscodingPrivatePayload { | ||
13 | videoUUID: string | ||
14 | isNewVideo: boolean | ||
15 | } | ||
16 | |||
17 | export interface RunnerJobVODAudioMergeTranscodingPrivatePayload { | ||
18 | videoUUID: string | ||
19 | isNewVideo: boolean | ||
20 | } | ||
21 | |||
22 | export interface RunnerJobVODHLSTranscodingPrivatePayload { | ||
23 | videoUUID: string | ||
24 | isNewVideo: boolean | ||
25 | deleteWebVideoFiles: boolean | ||
26 | } | ||
27 | |||
28 | // --------------------------------------------------------------------------- | ||
29 | |||
30 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export interface RunnerJobSuccessBody { | ||
2 | runnerToken: string | ||
3 | jobToken: string | ||
4 | |||
5 | payload: RunnerJobSuccessPayload | ||
6 | } | ||
7 | |||
8 | // --------------------------------------------------------------------------- | ||
9 | |||
10 | export type RunnerJobSuccessPayload = | ||
11 | VODWebVideoTranscodingSuccess | | ||
12 | VODHLSTranscodingSuccess | | ||
13 | VODAudioMergeTranscodingSuccess | | ||
14 | LiveRTMPHLSTranscodingSuccess | ||
15 | |||
16 | export interface VODWebVideoTranscodingSuccess { | ||
17 | videoFile: Blob | string | ||
18 | } | ||
19 | |||
20 | export interface VODHLSTranscodingSuccess { | ||
21 | videoFile: Blob | string | ||
22 | resolutionPlaylistFile: Blob | string | ||
23 | } | ||
24 | |||
25 | export interface VODAudioMergeTranscodingSuccess { | ||
26 | videoFile: Blob | string | ||
27 | } | ||
28 | |||
29 | export interface LiveRTMPHLSTranscodingSuccess { | ||
30 | |||
31 | } | ||
32 | |||
33 | export function isWebVideoOrAudioMergeTranscodingPayloadSuccess ( | ||
34 | payload: RunnerJobSuccessPayload | ||
35 | ): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess { | ||
36 | return !!(payload as VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess)?.videoFile | ||
37 | } | ||
38 | |||
39 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export interface RunnerJobUpdateBody { | ||
2 | runnerToken: string | ||
3 | jobToken: string | ||
4 | |||
5 | progress?: number | ||
6 | payload?: RunnerJobUpdatePayload | ||
7 | } | ||
8 | |||
9 | // --------------------------------------------------------------------------- | ||
10 | |||
11 | export type RunnerJobUpdatePayload = LiveRTMPHLSTranscodingUpdatePayload | ||
12 | |||
13 | export 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 | |||
25 | export 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 @@ | |||
1 | import { VideoConstant } from '../videos' | ||
2 | import { RunnerJobPayload } from './runner-job-payload.model' | ||
3 | import { RunnerJobPrivatePayload } from './runner-job-private-payload.model' | ||
4 | import { RunnerJobState } from './runner-job-state.model' | ||
5 | import { RunnerJobType } from './runner-job-type.type' | ||
6 | |||
7 | export 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 | ||
43 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export 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 | ||
46 | export type ActivitypubHttpBroadcastPayload = { | 51 | export type ActivitypubHttpBroadcastPayload = { |
@@ -139,30 +144,28 @@ interface BaseTranscodingPayload { | |||
139 | export interface HLSTranscodingPayload extends BaseTranscodingPayload { | 144 | export 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 | ||
150 | export interface NewWebTorrentResolutionTranscodingPayload extends BaseTranscodingPayload { | 153 | export 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 | ||
158 | export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { | 159 | export 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 | ||
164 | export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { | 165 | export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { |
165 | type: 'optimize-to-webtorrent' | 166 | type: 'optimize-to-webtorrent' |
167 | |||
168 | quickTranscode: boolean | ||
166 | } | 169 | } |
167 | 170 | ||
168 | export type VideoTranscodingPayload = | 171 | export 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 | |||
267 | export 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 | } |