diff options
author | Chocobozzz <me@florianbigard.com> | 2023-04-21 14:55:10 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2023-05-09 08:57:34 +0200 |
commit | 0c9668f77901e7540e2c7045eb0f2974a4842a69 (patch) | |
tree | 226d3dd1565b0bb56588897af3b8530e6216e96b /shared/ffmpeg | |
parent | 6bcb854cdea8688a32240bc5719c7d139806e00b (diff) | |
download | PeerTube-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/ffmpeg')
-rw-r--r-- | shared/ffmpeg/ffmpeg-command-wrapper.ts | 234 | ||||
-rw-r--r-- | shared/ffmpeg/ffmpeg-edition.ts | 239 | ||||
-rw-r--r-- | shared/ffmpeg/ffmpeg-images.ts | 59 | ||||
-rw-r--r-- | shared/ffmpeg/ffmpeg-live.ts | 184 | ||||
-rw-r--r-- | shared/ffmpeg/ffmpeg-utils.ts | 17 | ||||
-rw-r--r-- | shared/ffmpeg/ffmpeg-version.ts | 24 | ||||
-rw-r--r-- | shared/ffmpeg/ffmpeg-vod.ts | 256 | ||||
-rw-r--r-- | shared/ffmpeg/ffprobe.ts | 184 | ||||
-rw-r--r-- | shared/ffmpeg/index.ts | 8 | ||||
-rw-r--r-- | shared/ffmpeg/shared/encoder-options.ts | 39 | ||||
-rw-r--r-- | shared/ffmpeg/shared/index.ts | 2 | ||||
-rw-r--r-- | shared/ffmpeg/shared/presets.ts | 93 |
12 files changed, 1339 insertions, 0 deletions
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/ffmpeg/ffprobe.ts b/shared/ffmpeg/ffprobe.ts new file mode 100644 index 000000000..fda08c28e --- /dev/null +++ b/shared/ffmpeg/ffprobe.ts | |||
@@ -0,0 +1,184 @@ | |||
1 | import { ffprobe, FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { forceNumber } from '@shared/core-utils' | ||
3 | import { VideoResolution } from '@shared/models/videos' | ||
4 | |||
5 | /** | ||
6 | * | ||
7 | * Helpers to run ffprobe and extract data from the JSON output | ||
8 | * | ||
9 | */ | ||
10 | |||
11 | function ffprobePromise (path: string) { | ||
12 | return new Promise<FfprobeData>((res, rej) => { | ||
13 | ffprobe(path, (err, data) => { | ||
14 | if (err) return rej(err) | ||
15 | |||
16 | return res(data) | ||
17 | }) | ||
18 | }) | ||
19 | } | ||
20 | |||
21 | // --------------------------------------------------------------------------- | ||
22 | // Audio | ||
23 | // --------------------------------------------------------------------------- | ||
24 | |||
25 | const imageCodecs = new Set([ | ||
26 | 'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2', | ||
27 | 'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff', | ||
28 | 'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd' | ||
29 | ]) | ||
30 | |||
31 | async function isAudioFile (path: string, existingProbe?: FfprobeData) { | ||
32 | const videoStream = await getVideoStream(path, existingProbe) | ||
33 | if (!videoStream) return true | ||
34 | |||
35 | if (imageCodecs.has(videoStream.codec_name)) return true | ||
36 | |||
37 | return false | ||
38 | } | ||
39 | |||
40 | async function hasAudioStream (path: string, existingProbe?: FfprobeData) { | ||
41 | const { audioStream } = await getAudioStream(path, existingProbe) | ||
42 | |||
43 | return !!audioStream | ||
44 | } | ||
45 | |||
46 | async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) { | ||
47 | // without position, ffprobe considers the last input only | ||
48 | // we make it consider the first input only | ||
49 | // if you pass a file path to pos, then ffprobe acts on that file directly | ||
50 | const data = existingProbe || await ffprobePromise(videoPath) | ||
51 | |||
52 | if (Array.isArray(data.streams)) { | ||
53 | const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') | ||
54 | |||
55 | if (audioStream) { | ||
56 | return { | ||
57 | absolutePath: data.format.filename, | ||
58 | audioStream, | ||
59 | bitrate: forceNumber(audioStream['bit_rate']) | ||
60 | } | ||
61 | } | ||
62 | } | ||
63 | |||
64 | return { absolutePath: data.format.filename } | ||
65 | } | ||
66 | |||
67 | function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) { | ||
68 | const maxKBitrate = 384 | ||
69 | const kToBits = (kbits: number) => kbits * 1000 | ||
70 | |||
71 | // If we did not manage to get the bitrate, use an average value | ||
72 | if (!bitrate) return 256 | ||
73 | |||
74 | if (type === 'aac') { | ||
75 | switch (true) { | ||
76 | case bitrate > kToBits(maxKBitrate): | ||
77 | return maxKBitrate | ||
78 | |||
79 | default: | ||
80 | return -1 // we interpret it as a signal to copy the audio stream as is | ||
81 | } | ||
82 | } | ||
83 | |||
84 | /* | ||
85 | a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac. | ||
86 | That's why, when using aac, we can go to lower kbit/sec. The equivalences | ||
87 | made here are not made to be accurate, especially with good mp3 encoders. | ||
88 | */ | ||
89 | switch (true) { | ||
90 | case bitrate <= kToBits(192): | ||
91 | return 128 | ||
92 | |||
93 | case bitrate <= kToBits(384): | ||
94 | return 256 | ||
95 | |||
96 | default: | ||
97 | return maxKBitrate | ||
98 | } | ||
99 | } | ||
100 | |||
101 | // --------------------------------------------------------------------------- | ||
102 | // Video | ||
103 | // --------------------------------------------------------------------------- | ||
104 | |||
105 | async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) { | ||
106 | const videoStream = await getVideoStream(path, existingProbe) | ||
107 | if (!videoStream) { | ||
108 | return { | ||
109 | width: 0, | ||
110 | height: 0, | ||
111 | ratio: 0, | ||
112 | resolution: VideoResolution.H_NOVIDEO, | ||
113 | isPortraitMode: false | ||
114 | } | ||
115 | } | ||
116 | |||
117 | return { | ||
118 | width: videoStream.width, | ||
119 | height: videoStream.height, | ||
120 | ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width), | ||
121 | resolution: Math.min(videoStream.height, videoStream.width), | ||
122 | isPortraitMode: videoStream.height > videoStream.width | ||
123 | } | ||
124 | } | ||
125 | |||
126 | async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) { | ||
127 | const videoStream = await getVideoStream(path, existingProbe) | ||
128 | if (!videoStream) return 0 | ||
129 | |||
130 | for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { | ||
131 | const valuesText: string = videoStream[key] | ||
132 | if (!valuesText) continue | ||
133 | |||
134 | const [ frames, seconds ] = valuesText.split('/') | ||
135 | if (!frames || !seconds) continue | ||
136 | |||
137 | const result = parseInt(frames, 10) / parseInt(seconds, 10) | ||
138 | if (result > 0) return Math.round(result) | ||
139 | } | ||
140 | |||
141 | return 0 | ||
142 | } | ||
143 | |||
144 | async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> { | ||
145 | const metadata = existingProbe || await ffprobePromise(path) | ||
146 | |||
147 | let bitrate = metadata.format.bit_rate | ||
148 | if (bitrate && !isNaN(bitrate)) return bitrate | ||
149 | |||
150 | const videoStream = await getVideoStream(path, existingProbe) | ||
151 | if (!videoStream) return undefined | ||
152 | |||
153 | bitrate = forceNumber(videoStream?.bit_rate) | ||
154 | if (bitrate && !isNaN(bitrate)) return bitrate | ||
155 | |||
156 | return undefined | ||
157 | } | ||
158 | |||
159 | async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { | ||
160 | const metadata = existingProbe || await ffprobePromise(path) | ||
161 | |||
162 | return Math.round(metadata.format.duration) | ||
163 | } | ||
164 | |||
165 | async function getVideoStream (path: string, existingProbe?: FfprobeData) { | ||
166 | const metadata = existingProbe || await ffprobePromise(path) | ||
167 | |||
168 | return metadata.streams.find(s => s.codec_type === 'video') | ||
169 | } | ||
170 | |||
171 | // --------------------------------------------------------------------------- | ||
172 | |||
173 | export { | ||
174 | getVideoStreamDimensionsInfo, | ||
175 | getMaxAudioBitrate, | ||
176 | getVideoStream, | ||
177 | getVideoStreamDuration, | ||
178 | getAudioStream, | ||
179 | getVideoStreamFPS, | ||
180 | isAudioFile, | ||
181 | ffprobePromise, | ||
182 | getVideoStreamBitrate, | ||
183 | hasAudioStream | ||
184 | } | ||
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 | } | ||