diff options
Diffstat (limited to 'server/helpers/ffmpeg')
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-commons.ts | 114 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-edition.ts | 242 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-encoders.ts | 116 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-images.ts | 46 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-live.ts | 161 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-presets.ts | 156 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-vod.ts | 254 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffprobe-utils.ts | 231 | ||||
-rw-r--r-- | server/helpers/ffmpeg/index.ts | 8 |
9 files changed, 1328 insertions, 0 deletions
diff --git a/server/helpers/ffmpeg/ffmpeg-commons.ts b/server/helpers/ffmpeg/ffmpeg-commons.ts new file mode 100644 index 000000000..ee338889c --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-commons.ts | |||
@@ -0,0 +1,114 @@ | |||
1 | import { Job } from 'bull' | ||
2 | import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { execPromise } from '@server/helpers/core-utils' | ||
4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
7 | import { EncoderOptions } from '@shared/models' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | type StreamType = 'audio' | 'video' | ||
12 | |||
13 | function getFFmpeg (input: string, type: 'live' | 'vod') { | ||
14 | // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems | ||
15 | const command = ffmpeg(input, { | ||
16 | niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD, | ||
17 | cwd: CONFIG.STORAGE.TMP_DIR | ||
18 | }) | ||
19 | |||
20 | const threads = type === 'live' | ||
21 | ? CONFIG.LIVE.TRANSCODING.THREADS | ||
22 | : CONFIG.TRANSCODING.THREADS | ||
23 | |||
24 | if (threads > 0) { | ||
25 | // If we don't set any threads ffmpeg will chose automatically | ||
26 | command.outputOption('-threads ' + threads) | ||
27 | } | ||
28 | |||
29 | return command | ||
30 | } | ||
31 | |||
32 | function getFFmpegVersion () { | ||
33 | return new Promise<string>((res, rej) => { | ||
34 | (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => { | ||
35 | if (err) return rej(err) | ||
36 | if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path')) | ||
37 | |||
38 | return execPromise(`${ffmpegPath} -version`) | ||
39 | .then(stdout => { | ||
40 | const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/) | ||
41 | if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`)) | ||
42 | |||
43 | // Fix ffmpeg version that does not include patch version (4.4 for example) | ||
44 | let version = parsed[1] | ||
45 | if (version.match(/^\d+\.\d+$/)) { | ||
46 | version += '.0' | ||
47 | } | ||
48 | |||
49 | return res(version) | ||
50 | }) | ||
51 | .catch(err => rej(err)) | ||
52 | }) | ||
53 | }) | ||
54 | } | ||
55 | |||
56 | async function runCommand (options: { | ||
57 | command: FfmpegCommand | ||
58 | silent?: boolean // false by default | ||
59 | job?: Job | ||
60 | }) { | ||
61 | const { command, silent = false, job } = options | ||
62 | |||
63 | return new Promise<void>((res, rej) => { | ||
64 | let shellCommand: string | ||
65 | |||
66 | command.on('start', cmdline => { shellCommand = cmdline }) | ||
67 | |||
68 | command.on('error', (err, stdout, stderr) => { | ||
69 | if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() }) | ||
70 | |||
71 | rej(err) | ||
72 | }) | ||
73 | |||
74 | command.on('end', (stdout, stderr) => { | ||
75 | logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() }) | ||
76 | |||
77 | res() | ||
78 | }) | ||
79 | |||
80 | if (job) { | ||
81 | command.on('progress', progress => { | ||
82 | if (!progress.percent) return | ||
83 | |||
84 | job.progress(Math.round(progress.percent)) | ||
85 | .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() })) | ||
86 | }) | ||
87 | } | ||
88 | |||
89 | command.run() | ||
90 | }) | ||
91 | } | ||
92 | |||
93 | function buildStreamSuffix (base: string, streamNum?: number) { | ||
94 | if (streamNum !== undefined) { | ||
95 | return `${base}:${streamNum}` | ||
96 | } | ||
97 | |||
98 | return base | ||
99 | } | ||
100 | |||
101 | function getScaleFilter (options: EncoderOptions): string { | ||
102 | if (options.scaleFilter) return options.scaleFilter.name | ||
103 | |||
104 | return 'scale' | ||
105 | } | ||
106 | |||
107 | export { | ||
108 | getFFmpeg, | ||
109 | getFFmpegVersion, | ||
110 | runCommand, | ||
111 | StreamType, | ||
112 | buildStreamSuffix, | ||
113 | getScaleFilter | ||
114 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-edition.ts b/server/helpers/ffmpeg/ffmpeg-edition.ts new file mode 100644 index 000000000..a5baa7ef1 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-edition.ts | |||
@@ -0,0 +1,242 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { VIDEO_FILTERS } from '@server/initializers/constants' | ||
3 | import { AvailableEncoders } from '@shared/models' | ||
4 | import { logger, loggerTagsFactory } from '../logger' | ||
5 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
6 | import { presetCopy, presetVOD } from './ffmpeg-presets' | ||
7 | import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | async function cutVideo (options: { | ||
12 | inputPath: string | ||
13 | outputPath: string | ||
14 | start?: number | ||
15 | end?: number | ||
16 | }) { | ||
17 | const { inputPath, outputPath } = options | ||
18 | |||
19 | logger.debug('Will cut the video.', { options, ...lTags() }) | ||
20 | |||
21 | let command = getFFmpeg(inputPath, 'vod') | ||
22 | .output(outputPath) | ||
23 | |||
24 | command = presetCopy(command) | ||
25 | |||
26 | if (options.start) command.inputOption('-ss ' + options.start) | ||
27 | |||
28 | if (options.end) { | ||
29 | const endSeeking = options.end - (options.start || 0) | ||
30 | |||
31 | command.outputOption('-to ' + endSeeking) | ||
32 | } | ||
33 | |||
34 | await runCommand({ command }) | ||
35 | } | ||
36 | |||
37 | async function addWatermark (options: { | ||
38 | inputPath: string | ||
39 | watermarkPath: string | ||
40 | outputPath: string | ||
41 | |||
42 | availableEncoders: AvailableEncoders | ||
43 | profile: string | ||
44 | }) { | ||
45 | const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options | ||
46 | |||
47 | logger.debug('Will add watermark to the video.', { options, ...lTags() }) | ||
48 | |||
49 | const videoProbe = await ffprobePromise(inputPath) | ||
50 | const fps = await getVideoStreamFPS(inputPath, videoProbe) | ||
51 | const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe) | ||
52 | |||
53 | let command = getFFmpeg(inputPath, 'vod') | ||
54 | .output(outputPath) | ||
55 | command.input(watermarkPath) | ||
56 | |||
57 | command = await presetVOD({ | ||
58 | command, | ||
59 | input: inputPath, | ||
60 | availableEncoders, | ||
61 | profile, | ||
62 | resolution, | ||
63 | fps, | ||
64 | canCopyAudio: true, | ||
65 | canCopyVideo: false | ||
66 | }) | ||
67 | |||
68 | const complexFilter: FilterSpecification[] = [ | ||
69 | // Scale watermark | ||
70 | { | ||
71 | inputs: [ '[1]', '[0]' ], | ||
72 | filter: 'scale2ref', | ||
73 | options: { | ||
74 | w: 'oh*mdar', | ||
75 | h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}` | ||
76 | }, | ||
77 | outputs: [ '[watermark]', '[video]' ] | ||
78 | }, | ||
79 | |||
80 | { | ||
81 | inputs: [ '[video]', '[watermark]' ], | ||
82 | filter: 'overlay', | ||
83 | options: { | ||
84 | x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`, | ||
85 | y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}` | ||
86 | } | ||
87 | } | ||
88 | ] | ||
89 | |||
90 | command.complexFilter(complexFilter) | ||
91 | |||
92 | await runCommand({ command }) | ||
93 | } | ||
94 | |||
95 | async function addIntroOutro (options: { | ||
96 | inputPath: string | ||
97 | introOutroPath: string | ||
98 | outputPath: string | ||
99 | type: 'intro' | 'outro' | ||
100 | |||
101 | availableEncoders: AvailableEncoders | ||
102 | profile: string | ||
103 | }) { | ||
104 | const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options | ||
105 | |||
106 | logger.debug('Will add intro/outro to the video.', { options, ...lTags() }) | ||
107 | |||
108 | const mainProbe = await ffprobePromise(inputPath) | ||
109 | const fps = await getVideoStreamFPS(inputPath, mainProbe) | ||
110 | const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) | ||
111 | const mainHasAudio = await hasAudioStream(inputPath, mainProbe) | ||
112 | |||
113 | const introOutroProbe = await ffprobePromise(introOutroPath) | ||
114 | const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe) | ||
115 | |||
116 | let command = getFFmpeg(inputPath, 'vod') | ||
117 | .output(outputPath) | ||
118 | |||
119 | command.input(introOutroPath) | ||
120 | |||
121 | if (!introOutroHasAudio && mainHasAudio) { | ||
122 | const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe) | ||
123 | |||
124 | command.input('anullsrc') | ||
125 | command.withInputFormat('lavfi') | ||
126 | command.withInputOption('-t ' + duration) | ||
127 | } | ||
128 | |||
129 | command = await presetVOD({ | ||
130 | command, | ||
131 | input: inputPath, | ||
132 | availableEncoders, | ||
133 | profile, | ||
134 | resolution, | ||
135 | fps, | ||
136 | canCopyAudio: false, | ||
137 | canCopyVideo: false | ||
138 | }) | ||
139 | |||
140 | // Add black background to correctly scale intro/outro with padding | ||
141 | const complexFilter: FilterSpecification[] = [ | ||
142 | { | ||
143 | inputs: [ '1', '0' ], | ||
144 | filter: 'scale2ref', | ||
145 | options: { | ||
146 | w: 'iw', | ||
147 | h: `ih` | ||
148 | }, | ||
149 | outputs: [ 'intro-outro', 'main' ] | ||
150 | }, | ||
151 | { | ||
152 | inputs: [ 'intro-outro', 'main' ], | ||
153 | filter: 'scale2ref', | ||
154 | options: { | ||
155 | w: 'iw', | ||
156 | h: `ih` | ||
157 | }, | ||
158 | outputs: [ 'to-scale', 'main' ] | ||
159 | }, | ||
160 | { | ||
161 | inputs: 'to-scale', | ||
162 | filter: 'drawbox', | ||
163 | options: { | ||
164 | t: 'fill' | ||
165 | }, | ||
166 | outputs: [ 'to-scale-bg' ] | ||
167 | }, | ||
168 | { | ||
169 | inputs: [ '1', 'to-scale-bg' ], | ||
170 | filter: 'scale2ref', | ||
171 | options: { | ||
172 | w: 'iw', | ||
173 | h: 'ih', | ||
174 | force_original_aspect_ratio: 'decrease', | ||
175 | flags: 'spline' | ||
176 | }, | ||
177 | outputs: [ 'to-scale', 'to-scale-bg' ] | ||
178 | }, | ||
179 | { | ||
180 | inputs: [ 'to-scale-bg', 'to-scale' ], | ||
181 | filter: 'overlay', | ||
182 | options: { | ||
183 | x: '(main_w - overlay_w)/2', | ||
184 | y: '(main_h - overlay_h)/2' | ||
185 | }, | ||
186 | outputs: 'intro-outro-resized' | ||
187 | } | ||
188 | ] | ||
189 | |||
190 | const concatFilter = { | ||
191 | inputs: [], | ||
192 | filter: 'concat', | ||
193 | options: { | ||
194 | n: 2, | ||
195 | v: 1, | ||
196 | unsafe: 1 | ||
197 | }, | ||
198 | outputs: [ 'v' ] | ||
199 | } | ||
200 | |||
201 | const introOutroFilterInputs = [ 'intro-outro-resized' ] | ||
202 | const mainFilterInputs = [ 'main' ] | ||
203 | |||
204 | if (mainHasAudio) { | ||
205 | mainFilterInputs.push('0:a') | ||
206 | |||
207 | if (introOutroHasAudio) { | ||
208 | introOutroFilterInputs.push('1:a') | ||
209 | } else { | ||
210 | // Silent input | ||
211 | introOutroFilterInputs.push('2:a') | ||
212 | } | ||
213 | } | ||
214 | |||
215 | if (type === 'intro') { | ||
216 | concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ] | ||
217 | } else { | ||
218 | concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ] | ||
219 | } | ||
220 | |||
221 | if (mainHasAudio) { | ||
222 | concatFilter.options['a'] = 1 | ||
223 | concatFilter.outputs.push('a') | ||
224 | |||
225 | command.outputOption('-map [a]') | ||
226 | } | ||
227 | |||
228 | command.outputOption('-map [v]') | ||
229 | |||
230 | complexFilter.push(concatFilter) | ||
231 | command.complexFilter(complexFilter) | ||
232 | |||
233 | await runCommand({ command }) | ||
234 | } | ||
235 | |||
236 | // --------------------------------------------------------------------------- | ||
237 | |||
238 | export { | ||
239 | cutVideo, | ||
240 | addIntroOutro, | ||
241 | addWatermark | ||
242 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-encoders.ts b/server/helpers/ffmpeg/ffmpeg-encoders.ts new file mode 100644 index 000000000..5bd80ba05 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-encoders.ts | |||
@@ -0,0 +1,116 @@ | |||
1 | import { getAvailableEncoders } from 'fluent-ffmpeg' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models' | ||
4 | import { promisify0 } from '../core-utils' | ||
5 | import { logger, loggerTagsFactory } from '../logger' | ||
6 | |||
7 | const lTags = loggerTagsFactory('ffmpeg') | ||
8 | |||
9 | // Detect supported encoders by ffmpeg | ||
10 | let supportedEncoders: Map<string, boolean> | ||
11 | async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> { | ||
12 | if (supportedEncoders !== undefined) { | ||
13 | return supportedEncoders | ||
14 | } | ||
15 | |||
16 | const getAvailableEncodersPromise = promisify0(getAvailableEncoders) | ||
17 | const availableFFmpegEncoders = await getAvailableEncodersPromise() | ||
18 | |||
19 | const searchEncoders = new Set<string>() | ||
20 | for (const type of [ 'live', 'vod' ]) { | ||
21 | for (const streamType of [ 'audio', 'video' ]) { | ||
22 | for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) { | ||
23 | searchEncoders.add(encoder) | ||
24 | } | ||
25 | } | ||
26 | } | ||
27 | |||
28 | supportedEncoders = new Map<string, boolean>() | ||
29 | |||
30 | for (const searchEncoder of searchEncoders) { | ||
31 | supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined) | ||
32 | } | ||
33 | |||
34 | logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() }) | ||
35 | |||
36 | return supportedEncoders | ||
37 | } | ||
38 | |||
39 | function resetSupportedEncoders () { | ||
40 | supportedEncoders = undefined | ||
41 | } | ||
42 | |||
43 | // Run encoder builder depending on available encoders | ||
44 | // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one | ||
45 | // If the default one does not exist, check the next encoder | ||
46 | async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & { | ||
47 | streamType: 'video' | 'audio' | ||
48 | input: string | ||
49 | |||
50 | availableEncoders: AvailableEncoders | ||
51 | profile: string | ||
52 | |||
53 | videoType: 'vod' | 'live' | ||
54 | }) { | ||
55 | const { availableEncoders, profile, streamType, videoType } = options | ||
56 | |||
57 | const encodersToTry = availableEncoders.encodersToTry[videoType][streamType] | ||
58 | const encoders = availableEncoders.available[videoType] | ||
59 | |||
60 | for (const encoder of encodersToTry) { | ||
61 | if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) { | ||
62 | logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags()) | ||
63 | continue | ||
64 | } | ||
65 | |||
66 | if (!encoders[encoder]) { | ||
67 | logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags()) | ||
68 | continue | ||
69 | } | ||
70 | |||
71 | // An object containing available profiles for this encoder | ||
72 | const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder] | ||
73 | let builder = builderProfiles[profile] | ||
74 | |||
75 | if (!builder) { | ||
76 | logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags()) | ||
77 | builder = builderProfiles.default | ||
78 | |||
79 | if (!builder) { | ||
80 | logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags()) | ||
81 | continue | ||
82 | } | ||
83 | } | ||
84 | |||
85 | const result = await builder( | ||
86 | pick(options, [ | ||
87 | 'input', | ||
88 | 'canCopyAudio', | ||
89 | 'canCopyVideo', | ||
90 | 'resolution', | ||
91 | 'inputBitrate', | ||
92 | 'fps', | ||
93 | 'inputRatio', | ||
94 | 'streamNum' | ||
95 | ]) | ||
96 | ) | ||
97 | |||
98 | return { | ||
99 | result, | ||
100 | |||
101 | // If we don't have output options, then copy the input stream | ||
102 | encoder: result.copy === true | ||
103 | ? 'copy' | ||
104 | : encoder | ||
105 | } | ||
106 | } | ||
107 | |||
108 | return null | ||
109 | } | ||
110 | |||
111 | export { | ||
112 | checkFFmpegEncoders, | ||
113 | resetSupportedEncoders, | ||
114 | |||
115 | getEncoderBuilderResult | ||
116 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-images.ts b/server/helpers/ffmpeg/ffmpeg-images.ts new file mode 100644 index 000000000..7f64c6d0a --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-images.ts | |||
@@ -0,0 +1,46 @@ | |||
1 | import ffmpeg from 'fluent-ffmpeg' | ||
2 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
3 | import { runCommand } from './ffmpeg-commons' | ||
4 | |||
5 | function convertWebPToJPG (path: string, destination: string): Promise<void> { | ||
6 | const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL }) | ||
7 | .output(destination) | ||
8 | |||
9 | return runCommand({ command, silent: true }) | ||
10 | } | ||
11 | |||
12 | function processGIF ( | ||
13 | path: string, | ||
14 | destination: string, | ||
15 | newSize: { width: number, height: number } | ||
16 | ): Promise<void> { | ||
17 | const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL }) | ||
18 | .fps(20) | ||
19 | .size(`${newSize.width}x${newSize.height}`) | ||
20 | .output(destination) | ||
21 | |||
22 | return runCommand({ command }) | ||
23 | } | ||
24 | |||
25 | async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) { | ||
26 | const pendingImageName = 'pending-' + imageName | ||
27 | |||
28 | const options = { | ||
29 | filename: pendingImageName, | ||
30 | count: 1, | ||
31 | folder | ||
32 | } | ||
33 | |||
34 | return new Promise<string>((res, rej) => { | ||
35 | ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL }) | ||
36 | .on('error', rej) | ||
37 | .on('end', () => res(imageName)) | ||
38 | .thumbnail(options) | ||
39 | }) | ||
40 | } | ||
41 | |||
42 | export { | ||
43 | convertWebPToJPG, | ||
44 | processGIF, | ||
45 | generateThumbnailFromVideo | ||
46 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-live.ts b/server/helpers/ffmpeg/ffmpeg-live.ts new file mode 100644 index 000000000..ff571626c --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-live.ts | |||
@@ -0,0 +1,161 @@ | |||
1 | import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { join } from 'path' | ||
3 | import { VIDEO_LIVE } from '@server/initializers/constants' | ||
4 | import { AvailableEncoders } from '@shared/models' | ||
5 | import { logger, loggerTagsFactory } from '../logger' | ||
6 | import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons' | ||
7 | import { getEncoderBuilderResult } from './ffmpeg-encoders' | ||
8 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets' | ||
9 | import { computeFPS } from './ffprobe-utils' | ||
10 | |||
11 | const lTags = loggerTagsFactory('ffmpeg') | ||
12 | |||
13 | async function getLiveTranscodingCommand (options: { | ||
14 | inputUrl: string | ||
15 | |||
16 | outPath: string | ||
17 | masterPlaylistName: string | ||
18 | |||
19 | resolutions: number[] | ||
20 | |||
21 | // Input information | ||
22 | fps: number | ||
23 | bitrate: number | ||
24 | ratio: number | ||
25 | |||
26 | availableEncoders: AvailableEncoders | ||
27 | profile: string | ||
28 | }) { | ||
29 | const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options | ||
30 | |||
31 | const command = getFFmpeg(inputUrl, 'live') | ||
32 | |||
33 | const varStreamMap: string[] = [] | ||
34 | |||
35 | const complexFilter: FilterSpecification[] = [ | ||
36 | { | ||
37 | inputs: '[v:0]', | ||
38 | filter: 'split', | ||
39 | options: resolutions.length, | ||
40 | outputs: resolutions.map(r => `vtemp${r}`) | ||
41 | } | ||
42 | ] | ||
43 | |||
44 | command.outputOption('-sc_threshold 0') | ||
45 | |||
46 | addDefaultEncoderGlobalParams(command) | ||
47 | |||
48 | for (let i = 0; i < resolutions.length; i++) { | ||
49 | const resolution = resolutions[i] | ||
50 | const resolutionFPS = computeFPS(fps, resolution) | ||
51 | |||
52 | const baseEncoderBuilderParams = { | ||
53 | input: inputUrl, | ||
54 | |||
55 | availableEncoders, | ||
56 | profile, | ||
57 | |||
58 | canCopyAudio: true, | ||
59 | canCopyVideo: true, | ||
60 | |||
61 | inputBitrate: bitrate, | ||
62 | inputRatio: ratio, | ||
63 | |||
64 | resolution, | ||
65 | fps: resolutionFPS, | ||
66 | |||
67 | streamNum: i, | ||
68 | videoType: 'live' as 'live' | ||
69 | } | ||
70 | |||
71 | { | ||
72 | const streamType: StreamType = 'video' | ||
73 | const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) | ||
74 | if (!builderResult) { | ||
75 | throw new Error('No available live video encoder found') | ||
76 | } | ||
77 | |||
78 | command.outputOption(`-map [vout${resolution}]`) | ||
79 | |||
80 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) | ||
81 | |||
82 | logger.debug( | ||
83 | 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile, | ||
84 | { builderResult, fps: resolutionFPS, resolution, ...lTags() } | ||
85 | ) | ||
86 | |||
87 | command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) | ||
88 | applyEncoderOptions(command, builderResult.result) | ||
89 | |||
90 | complexFilter.push({ | ||
91 | inputs: `vtemp${resolution}`, | ||
92 | filter: getScaleFilter(builderResult.result), | ||
93 | options: `w=-2:h=${resolution}`, | ||
94 | outputs: `vout${resolution}` | ||
95 | }) | ||
96 | } | ||
97 | |||
98 | { | ||
99 | const streamType: StreamType = 'audio' | ||
100 | const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) | ||
101 | if (!builderResult) { | ||
102 | throw new Error('No available live audio encoder found') | ||
103 | } | ||
104 | |||
105 | command.outputOption('-map a:0') | ||
106 | |||
107 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) | ||
108 | |||
109 | logger.debug( | ||
110 | 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile, | ||
111 | { builderResult, fps: resolutionFPS, resolution, ...lTags() } | ||
112 | ) | ||
113 | |||
114 | command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) | ||
115 | applyEncoderOptions(command, builderResult.result) | ||
116 | } | ||
117 | |||
118 | varStreamMap.push(`v:${i},a:${i}`) | ||
119 | } | ||
120 | |||
121 | command.complexFilter(complexFilter) | ||
122 | |||
123 | addDefaultLiveHLSParams(command, outPath, masterPlaylistName) | ||
124 | |||
125 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) | ||
126 | |||
127 | return command | ||
128 | } | ||
129 | |||
130 | function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) { | ||
131 | const command = getFFmpeg(inputUrl, 'live') | ||
132 | |||
133 | command.outputOption('-c:v copy') | ||
134 | command.outputOption('-c:a copy') | ||
135 | command.outputOption('-map 0:a?') | ||
136 | command.outputOption('-map 0:v?') | ||
137 | |||
138 | addDefaultLiveHLSParams(command, outPath, masterPlaylistName) | ||
139 | |||
140 | return command | ||
141 | } | ||
142 | |||
143 | // --------------------------------------------------------------------------- | ||
144 | |||
145 | export { | ||
146 | getLiveTranscodingCommand, | ||
147 | getLiveMuxingCommand | ||
148 | } | ||
149 | |||
150 | // --------------------------------------------------------------------------- | ||
151 | |||
152 | function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) { | ||
153 | command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) | ||
154 | command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) | ||
155 | command.outputOption('-hls_flags delete_segments+independent_segments') | ||
156 | command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) | ||
157 | command.outputOption('-master_pl_name ' + masterPlaylistName) | ||
158 | command.outputOption(`-f hls`) | ||
159 | |||
160 | command.output(join(outPath, '%v.m3u8')) | ||
161 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-presets.ts b/server/helpers/ffmpeg/ffmpeg-presets.ts new file mode 100644 index 000000000..99b39f79a --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-presets.ts | |||
@@ -0,0 +1,156 @@ | |||
1 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { pick } from 'lodash' | ||
3 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
4 | import { AvailableEncoders, EncoderOptions } from '@shared/models' | ||
5 | import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons' | ||
6 | import { getEncoderBuilderResult } from './ffmpeg-encoders' | ||
7 | import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils' | ||
8 | |||
9 | const lTags = loggerTagsFactory('ffmpeg') | ||
10 | |||
11 | // --------------------------------------------------------------------------- | ||
12 | |||
13 | function addDefaultEncoderGlobalParams (command: FfmpegCommand) { | ||
14 | // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375 | ||
15 | command.outputOption('-max_muxing_queue_size 1024') | ||
16 | // strip all metadata | ||
17 | .outputOption('-map_metadata -1') | ||
18 | // allows import of source material with incompatible pixel formats (e.g. MJPEG video) | ||
19 | .outputOption('-pix_fmt yuv420p') | ||
20 | } | ||
21 | |||
22 | function addDefaultEncoderParams (options: { | ||
23 | command: FfmpegCommand | ||
24 | encoder: 'libx264' | string | ||
25 | fps: number | ||
26 | |||
27 | streamNum?: number | ||
28 | }) { | ||
29 | const { command, encoder, fps, streamNum } = options | ||
30 | |||
31 | if (encoder === 'libx264') { | ||
32 | // 3.1 is the minimal resource allocation for our highest supported resolution | ||
33 | command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1') | ||
34 | |||
35 | if (fps) { | ||
36 | // Keyframe interval of 2 seconds for faster seeking and resolution switching. | ||
37 | // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html | ||
38 | // https://superuser.com/a/908325 | ||
39 | command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2)) | ||
40 | } | ||
41 | } | ||
42 | } | ||
43 | |||
44 | // --------------------------------------------------------------------------- | ||
45 | |||
46 | async function presetVOD (options: { | ||
47 | command: FfmpegCommand | ||
48 | input: string | ||
49 | |||
50 | availableEncoders: AvailableEncoders | ||
51 | profile: string | ||
52 | |||
53 | canCopyAudio: boolean | ||
54 | canCopyVideo: boolean | ||
55 | |||
56 | resolution: number | ||
57 | fps: number | ||
58 | |||
59 | scaleFilterValue?: string | ||
60 | }) { | ||
61 | const { command, input, profile, resolution, fps, scaleFilterValue } = options | ||
62 | |||
63 | let localCommand = command | ||
64 | .format('mp4') | ||
65 | .outputOption('-movflags faststart') | ||
66 | |||
67 | addDefaultEncoderGlobalParams(command) | ||
68 | |||
69 | const probe = await ffprobePromise(input) | ||
70 | |||
71 | // Audio encoder | ||
72 | const bitrate = await getVideoStreamBitrate(input, probe) | ||
73 | const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe) | ||
74 | |||
75 | let streamsToProcess: StreamType[] = [ 'audio', 'video' ] | ||
76 | |||
77 | if (!await hasAudioStream(input, probe)) { | ||
78 | localCommand = localCommand.noAudio() | ||
79 | streamsToProcess = [ 'video' ] | ||
80 | } | ||
81 | |||
82 | for (const streamType of streamsToProcess) { | ||
83 | const builderResult = await getEncoderBuilderResult({ | ||
84 | ...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]), | ||
85 | |||
86 | input, | ||
87 | inputBitrate: bitrate, | ||
88 | inputRatio: videoStreamDimensions?.ratio || 0, | ||
89 | |||
90 | profile, | ||
91 | resolution, | ||
92 | fps, | ||
93 | streamType, | ||
94 | |||
95 | videoType: 'vod' as 'vod' | ||
96 | }) | ||
97 | |||
98 | if (!builderResult) { | ||
99 | throw new Error('No available encoder found for stream ' + streamType) | ||
100 | } | ||
101 | |||
102 | logger.debug( | ||
103 | 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.', | ||
104 | builderResult.encoder, streamType, input, profile, | ||
105 | { builderResult, resolution, fps, ...lTags() } | ||
106 | ) | ||
107 | |||
108 | if (streamType === 'video') { | ||
109 | localCommand.videoCodec(builderResult.encoder) | ||
110 | |||
111 | if (scaleFilterValue) { | ||
112 | localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`) | ||
113 | } | ||
114 | } else if (streamType === 'audio') { | ||
115 | localCommand.audioCodec(builderResult.encoder) | ||
116 | } | ||
117 | |||
118 | applyEncoderOptions(localCommand, builderResult.result) | ||
119 | addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps }) | ||
120 | } | ||
121 | |||
122 | return localCommand | ||
123 | } | ||
124 | |||
125 | function presetCopy (command: FfmpegCommand): FfmpegCommand { | ||
126 | return command | ||
127 | .format('mp4') | ||
128 | .videoCodec('copy') | ||
129 | .audioCodec('copy') | ||
130 | } | ||
131 | |||
132 | function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand { | ||
133 | return command | ||
134 | .format('mp4') | ||
135 | .audioCodec('copy') | ||
136 | .noVideo() | ||
137 | } | ||
138 | |||
139 | function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand { | ||
140 | return command | ||
141 | .inputOptions(options.inputOptions ?? []) | ||
142 | .outputOptions(options.outputOptions ?? []) | ||
143 | } | ||
144 | |||
145 | // --------------------------------------------------------------------------- | ||
146 | |||
147 | export { | ||
148 | presetVOD, | ||
149 | presetCopy, | ||
150 | presetOnlyAudio, | ||
151 | |||
152 | addDefaultEncoderGlobalParams, | ||
153 | addDefaultEncoderParams, | ||
154 | |||
155 | applyEncoderOptions | ||
156 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-vod.ts b/server/helpers/ffmpeg/ffmpeg-vod.ts new file mode 100644 index 000000000..c3622ceb1 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-vod.ts | |||
@@ -0,0 +1,254 @@ | |||
1 | import { Job } from 'bull' | ||
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 { AvailableEncoders, VideoResolution } from '@shared/models' | ||
7 | import { logger, loggerTagsFactory } from '../logger' | ||
8 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
9 | import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets' | ||
10 | import { computeFPS, getVideoStreamFPS } from './ffprobe-utils' | ||
11 | import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' | ||
12 | |||
13 | const lTags = loggerTagsFactory('ffmpeg') | ||
14 | |||
15 | // --------------------------------------------------------------------------- | ||
16 | |||
17 | type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
18 | |||
19 | interface BaseTranscodeVODOptions { | ||
20 | type: TranscodeVODOptionsType | ||
21 | |||
22 | inputPath: string | ||
23 | outputPath: string | ||
24 | |||
25 | availableEncoders: AvailableEncoders | ||
26 | profile: string | ||
27 | |||
28 | resolution: number | ||
29 | |||
30 | isPortraitMode?: boolean | ||
31 | |||
32 | job?: Job | ||
33 | } | ||
34 | |||
35 | interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | ||
36 | type: 'hls' | ||
37 | copyCodecs: boolean | ||
38 | hlsPlaylist: { | ||
39 | videoFilename: string | ||
40 | } | ||
41 | } | ||
42 | |||
43 | interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | ||
44 | type: 'hls-from-ts' | ||
45 | |||
46 | isAAC: boolean | ||
47 | |||
48 | hlsPlaylist: { | ||
49 | videoFilename: string | ||
50 | } | ||
51 | } | ||
52 | |||
53 | interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | ||
54 | type: 'quick-transcode' | ||
55 | } | ||
56 | |||
57 | interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | ||
58 | type: 'video' | ||
59 | } | ||
60 | |||
61 | interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
62 | type: 'merge-audio' | ||
63 | audioPath: string | ||
64 | } | ||
65 | |||
66 | interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
67 | type: 'only-audio' | ||
68 | } | ||
69 | |||
70 | type TranscodeVODOptions = | ||
71 | HLSTranscodeOptions | ||
72 | | HLSFromTSTranscodeOptions | ||
73 | | VideoTranscodeOptions | ||
74 | | MergeAudioTranscodeOptions | ||
75 | | OnlyAudioTranscodeOptions | ||
76 | | QuickTranscodeOptions | ||
77 | |||
78 | // --------------------------------------------------------------------------- | ||
79 | |||
80 | const builders: { | ||
81 | [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand | ||
82 | } = { | ||
83 | 'quick-transcode': buildQuickTranscodeCommand, | ||
84 | 'hls': buildHLSVODCommand, | ||
85 | 'hls-from-ts': buildHLSVODFromTSCommand, | ||
86 | 'merge-audio': buildAudioMergeCommand, | ||
87 | 'only-audio': buildOnlyAudioCommand, | ||
88 | 'video': buildVODCommand | ||
89 | } | ||
90 | |||
91 | async function transcodeVOD (options: TranscodeVODOptions) { | ||
92 | logger.debug('Will run transcode.', { options, ...lTags() }) | ||
93 | |||
94 | let command = getFFmpeg(options.inputPath, 'vod') | ||
95 | .output(options.outputPath) | ||
96 | |||
97 | command = await builders[options.type](command, options) | ||
98 | |||
99 | await runCommand({ command, job: options.job }) | ||
100 | |||
101 | await fixHLSPlaylistIfNeeded(options) | ||
102 | } | ||
103 | |||
104 | // --------------------------------------------------------------------------- | ||
105 | |||
106 | export { | ||
107 | transcodeVOD, | ||
108 | |||
109 | buildVODCommand, | ||
110 | |||
111 | TranscodeVODOptions, | ||
112 | TranscodeVODOptionsType | ||
113 | } | ||
114 | |||
115 | // --------------------------------------------------------------------------- | ||
116 | |||
117 | async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) { | ||
118 | let fps = await getVideoStreamFPS(options.inputPath) | ||
119 | fps = computeFPS(fps, options.resolution) | ||
120 | |||
121 | let scaleFilterValue: string | ||
122 | |||
123 | if (options.resolution !== undefined) { | ||
124 | scaleFilterValue = options.isPortraitMode === true | ||
125 | ? `w=${options.resolution}:h=-2` | ||
126 | : `w=-2:h=${options.resolution}` | ||
127 | } | ||
128 | |||
129 | command = await presetVOD({ | ||
130 | ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]), | ||
131 | |||
132 | command, | ||
133 | input: options.inputPath, | ||
134 | canCopyAudio: true, | ||
135 | canCopyVideo: true, | ||
136 | fps, | ||
137 | scaleFilterValue | ||
138 | }) | ||
139 | |||
140 | return command | ||
141 | } | ||
142 | |||
143 | function buildQuickTranscodeCommand (command: FfmpegCommand) { | ||
144 | command = presetCopy(command) | ||
145 | |||
146 | command = command.outputOption('-map_metadata -1') // strip all metadata | ||
147 | .outputOption('-movflags faststart') | ||
148 | |||
149 | return command | ||
150 | } | ||
151 | |||
152 | // --------------------------------------------------------------------------- | ||
153 | // Audio transcoding | ||
154 | // --------------------------------------------------------------------------- | ||
155 | |||
156 | async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) { | ||
157 | command = command.loop(undefined) | ||
158 | |||
159 | const scaleFilterValue = getMergeAudioScaleFilterValue() | ||
160 | command = await presetVOD({ | ||
161 | ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]), | ||
162 | |||
163 | command, | ||
164 | input: options.audioPath, | ||
165 | canCopyAudio: true, | ||
166 | canCopyVideo: true, | ||
167 | fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE, | ||
168 | scaleFilterValue | ||
169 | }) | ||
170 | |||
171 | command.outputOption('-preset:v veryfast') | ||
172 | |||
173 | command = command.input(options.audioPath) | ||
174 | .outputOption('-tune stillimage') | ||
175 | .outputOption('-shortest') | ||
176 | |||
177 | return command | ||
178 | } | ||
179 | |||
180 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
181 | command = presetOnlyAudio(command) | ||
182 | |||
183 | return command | ||
184 | } | ||
185 | |||
186 | // --------------------------------------------------------------------------- | ||
187 | // HLS transcoding | ||
188 | // --------------------------------------------------------------------------- | ||
189 | |||
190 | async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) { | ||
191 | const videoPath = getHLSVideoPath(options) | ||
192 | |||
193 | if (options.copyCodecs) command = presetCopy(command) | ||
194 | else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) | ||
195 | else command = await buildVODCommand(command, options) | ||
196 | |||
197 | addCommonHLSVODCommandOptions(command, videoPath) | ||
198 | |||
199 | return command | ||
200 | } | ||
201 | |||
202 | function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) { | ||
203 | const videoPath = getHLSVideoPath(options) | ||
204 | |||
205 | command.outputOption('-c copy') | ||
206 | |||
207 | if (options.isAAC) { | ||
208 | // Required for example when copying an AAC stream from an MPEG-TS | ||
209 | // Since it's a bitstream filter, we don't need to reencode the audio | ||
210 | command.outputOption('-bsf:a aac_adtstoasc') | ||
211 | } | ||
212 | |||
213 | addCommonHLSVODCommandOptions(command, videoPath) | ||
214 | |||
215 | return command | ||
216 | } | ||
217 | |||
218 | function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) { | ||
219 | return command.outputOption('-hls_time 4') | ||
220 | .outputOption('-hls_list_size 0') | ||
221 | .outputOption('-hls_playlist_type vod') | ||
222 | .outputOption('-hls_segment_filename ' + outputPath) | ||
223 | .outputOption('-hls_segment_type fmp4') | ||
224 | .outputOption('-f hls') | ||
225 | .outputOption('-hls_flags single_file') | ||
226 | } | ||
227 | |||
228 | async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) { | ||
229 | if (options.type !== 'hls' && options.type !== 'hls-from-ts') return | ||
230 | |||
231 | const fileContent = await readFile(options.outputPath) | ||
232 | |||
233 | const videoFileName = options.hlsPlaylist.videoFilename | ||
234 | const videoFilePath = getHLSVideoPath(options) | ||
235 | |||
236 | // Fix wrong mapping with some ffmpeg versions | ||
237 | const newContent = fileContent.toString() | ||
238 | .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) | ||
239 | |||
240 | await writeFile(options.outputPath, newContent) | ||
241 | } | ||
242 | |||
243 | // --------------------------------------------------------------------------- | ||
244 | // Helpers | ||
245 | // --------------------------------------------------------------------------- | ||
246 | |||
247 | function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { | ||
248 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
249 | } | ||
250 | |||
251 | // Avoid "height not divisible by 2" error | ||
252 | function getMergeAudioScaleFilterValue () { | ||
253 | return 'trunc(iw/2)*2:trunc(ih/2)*2' | ||
254 | } | ||
diff --git a/server/helpers/ffmpeg/ffprobe-utils.ts b/server/helpers/ffmpeg/ffprobe-utils.ts new file mode 100644 index 000000000..07bcf01f4 --- /dev/null +++ b/server/helpers/ffmpeg/ffprobe-utils.ts | |||
@@ -0,0 +1,231 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { getMaxBitrate } from '@shared/core-utils' | ||
3 | import { | ||
4 | ffprobePromise, | ||
5 | getAudioStream, | ||
6 | getVideoStreamDuration, | ||
7 | getMaxAudioBitrate, | ||
8 | buildFileMetadata, | ||
9 | getVideoStreamBitrate, | ||
10 | getVideoStreamFPS, | ||
11 | getVideoStream, | ||
12 | getVideoStreamDimensionsInfo, | ||
13 | hasAudioStream | ||
14 | } from '@shared/extra-utils/ffprobe' | ||
15 | import { VideoResolution, VideoTranscodingFPS } from '@shared/models' | ||
16 | import { CONFIG } from '../../initializers/config' | ||
17 | import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants' | ||
18 | import { logger } from '../logger' | ||
19 | |||
20 | /** | ||
21 | * | ||
22 | * Helpers to run ffprobe and extract data from the JSON output | ||
23 | * | ||
24 | */ | ||
25 | |||
26 | // --------------------------------------------------------------------------- | ||
27 | // Codecs | ||
28 | // --------------------------------------------------------------------------- | ||
29 | |||
30 | async function getVideoStreamCodec (path: string) { | ||
31 | const videoStream = await getVideoStream(path) | ||
32 | if (!videoStream) return '' | ||
33 | |||
34 | const videoCodec = videoStream.codec_tag_string | ||
35 | |||
36 | if (videoCodec === 'vp09') return 'vp09.00.50.08' | ||
37 | if (videoCodec === 'hev1') return 'hev1.1.6.L93.B0' | ||
38 | |||
39 | const baseProfileMatrix = { | ||
40 | avc1: { | ||
41 | High: '6400', | ||
42 | Main: '4D40', | ||
43 | Baseline: '42E0' | ||
44 | }, | ||
45 | av01: { | ||
46 | High: '1', | ||
47 | Main: '0', | ||
48 | Professional: '2' | ||
49 | } | ||
50 | } | ||
51 | |||
52 | let baseProfile = baseProfileMatrix[videoCodec][videoStream.profile] | ||
53 | if (!baseProfile) { | ||
54 | logger.warn('Cannot get video profile codec of %s.', path, { videoStream }) | ||
55 | baseProfile = baseProfileMatrix[videoCodec]['High'] // Fallback | ||
56 | } | ||
57 | |||
58 | if (videoCodec === 'av01') { | ||
59 | const level = videoStream.level | ||
60 | |||
61 | // Guess the tier indicator and bit depth | ||
62 | return `${videoCodec}.${baseProfile}.${level}M.08` | ||
63 | } | ||
64 | |||
65 | // Default, h264 codec | ||
66 | let level = videoStream.level.toString(16) | ||
67 | if (level.length === 1) level = `0${level}` | ||
68 | |||
69 | return `${videoCodec}.${baseProfile}${level}` | ||
70 | } | ||
71 | |||
72 | async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) { | ||
73 | const { audioStream } = await getAudioStream(path, existingProbe) | ||
74 | |||
75 | if (!audioStream) return '' | ||
76 | |||
77 | const audioCodecName = audioStream.codec_name | ||
78 | |||
79 | if (audioCodecName === 'opus') return 'opus' | ||
80 | if (audioCodecName === 'vorbis') return 'vorbis' | ||
81 | if (audioCodecName === 'aac') return 'mp4a.40.2' | ||
82 | |||
83 | logger.warn('Cannot get audio codec of %s.', path, { audioStream }) | ||
84 | |||
85 | return 'mp4a.40.2' // Fallback | ||
86 | } | ||
87 | |||
88 | // --------------------------------------------------------------------------- | ||
89 | // Resolutions | ||
90 | // --------------------------------------------------------------------------- | ||
91 | |||
92 | function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { | ||
93 | const configResolutions = type === 'vod' | ||
94 | ? CONFIG.TRANSCODING.RESOLUTIONS | ||
95 | : CONFIG.LIVE.TRANSCODING.RESOLUTIONS | ||
96 | |||
97 | const resolutionsEnabled: number[] = [] | ||
98 | |||
99 | // Put in the order we want to proceed jobs | ||
100 | const resolutions: VideoResolution[] = [ | ||
101 | VideoResolution.H_NOVIDEO, | ||
102 | VideoResolution.H_480P, | ||
103 | VideoResolution.H_360P, | ||
104 | VideoResolution.H_720P, | ||
105 | VideoResolution.H_240P, | ||
106 | VideoResolution.H_144P, | ||
107 | VideoResolution.H_1080P, | ||
108 | VideoResolution.H_1440P, | ||
109 | VideoResolution.H_4K | ||
110 | ] | ||
111 | |||
112 | for (const resolution of resolutions) { | ||
113 | if (configResolutions[resolution + 'p'] === true && videoFileResolution > resolution) { | ||
114 | resolutionsEnabled.push(resolution) | ||
115 | } | ||
116 | } | ||
117 | |||
118 | return resolutionsEnabled | ||
119 | } | ||
120 | |||
121 | // --------------------------------------------------------------------------- | ||
122 | // Can quick transcode | ||
123 | // --------------------------------------------------------------------------- | ||
124 | |||
125 | async function canDoQuickTranscode (path: string): Promise<boolean> { | ||
126 | if (CONFIG.TRANSCODING.PROFILE !== 'default') return false | ||
127 | |||
128 | const probe = await ffprobePromise(path) | ||
129 | |||
130 | return await canDoQuickVideoTranscode(path, probe) && | ||
131 | await canDoQuickAudioTranscode(path, probe) | ||
132 | } | ||
133 | |||
134 | async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> { | ||
135 | const parsedAudio = await getAudioStream(path, probe) | ||
136 | |||
137 | if (!parsedAudio.audioStream) return true | ||
138 | |||
139 | if (parsedAudio.audioStream['codec_name'] !== 'aac') return false | ||
140 | |||
141 | const audioBitrate = parsedAudio.bitrate | ||
142 | if (!audioBitrate) return false | ||
143 | |||
144 | const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate) | ||
145 | if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false | ||
146 | |||
147 | const channelLayout = parsedAudio.audioStream['channel_layout'] | ||
148 | // Causes playback issues with Chrome | ||
149 | if (!channelLayout || channelLayout === 'unknown') return false | ||
150 | |||
151 | return true | ||
152 | } | ||
153 | |||
154 | async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { | ||
155 | const videoStream = await getVideoStream(path, probe) | ||
156 | const fps = await getVideoStreamFPS(path, probe) | ||
157 | const bitRate = await getVideoStreamBitrate(path, probe) | ||
158 | const resolutionData = await getVideoStreamDimensionsInfo(path, probe) | ||
159 | |||
160 | // If ffprobe did not manage to guess the bitrate | ||
161 | if (!bitRate) return false | ||
162 | |||
163 | // check video params | ||
164 | if (!videoStream) return false | ||
165 | if (videoStream['codec_name'] !== 'h264') return false | ||
166 | if (videoStream['pix_fmt'] !== 'yuv420p') return false | ||
167 | if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false | ||
168 | if (bitRate > getMaxBitrate({ ...resolutionData, fps })) return false | ||
169 | |||
170 | return true | ||
171 | } | ||
172 | |||
173 | // --------------------------------------------------------------------------- | ||
174 | // Framerate | ||
175 | // --------------------------------------------------------------------------- | ||
176 | |||
177 | function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { | ||
178 | return VIDEO_TRANSCODING_FPS[type].slice(0) | ||
179 | .sort((a, b) => fps % a - fps % b)[0] | ||
180 | } | ||
181 | |||
182 | function computeFPS (fpsArg: number, resolution: VideoResolution) { | ||
183 | let fps = fpsArg | ||
184 | |||
185 | if ( | ||
186 | // On small/medium resolutions, limit FPS | ||
187 | resolution !== undefined && | ||
188 | resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | ||
189 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | ||
190 | ) { | ||
191 | // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value | ||
192 | fps = getClosestFramerateStandard(fps, 'STANDARD') | ||
193 | } | ||
194 | |||
195 | // Hard FPS limits | ||
196 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD') | ||
197 | |||
198 | if (fps < VIDEO_TRANSCODING_FPS.MIN) { | ||
199 | throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${VIDEO_TRANSCODING_FPS.MIN}`) | ||
200 | } | ||
201 | |||
202 | return fps | ||
203 | } | ||
204 | |||
205 | // --------------------------------------------------------------------------- | ||
206 | |||
207 | export { | ||
208 | // Re export ffprobe utils | ||
209 | getVideoStreamDimensionsInfo, | ||
210 | buildFileMetadata, | ||
211 | getMaxAudioBitrate, | ||
212 | getVideoStream, | ||
213 | getVideoStreamDuration, | ||
214 | getAudioStream, | ||
215 | hasAudioStream, | ||
216 | getVideoStreamFPS, | ||
217 | ffprobePromise, | ||
218 | getVideoStreamBitrate, | ||
219 | |||
220 | getVideoStreamCodec, | ||
221 | getAudioStreamCodec, | ||
222 | |||
223 | computeFPS, | ||
224 | getClosestFramerateStandard, | ||
225 | |||
226 | computeLowerResolutionsToTranscode, | ||
227 | |||
228 | canDoQuickTranscode, | ||
229 | canDoQuickVideoTranscode, | ||
230 | canDoQuickAudioTranscode | ||
231 | } | ||
diff --git a/server/helpers/ffmpeg/index.ts b/server/helpers/ffmpeg/index.ts new file mode 100644 index 000000000..e3bb2013f --- /dev/null +++ b/server/helpers/ffmpeg/index.ts | |||
@@ -0,0 +1,8 @@ | |||
1 | export * from './ffmpeg-commons' | ||
2 | export * from './ffmpeg-edition' | ||
3 | export * from './ffmpeg-encoders' | ||
4 | export * from './ffmpeg-images' | ||
5 | export * from './ffmpeg-live' | ||
6 | export * from './ffmpeg-presets' | ||
7 | export * from './ffmpeg-vod' | ||
8 | export * from './ffprobe-utils' | ||