diff options
Diffstat (limited to 'server/helpers/ffmpeg')
-rw-r--r-- | server/helpers/ffmpeg/codecs.ts | 64 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-commons.ts | 114 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-edition.ts | 258 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-encoders.ts | 116 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-image.ts | 14 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-images.ts | 46 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-live.ts | 204 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-options.ts | 45 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-presets.ts | 156 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffmpeg-vod.ts | 267 | ||||
-rw-r--r-- | server/helpers/ffmpeg/ffprobe-utils.ts | 254 | ||||
-rw-r--r-- | server/helpers/ffmpeg/framerate.ts | 44 | ||||
-rw-r--r-- | server/helpers/ffmpeg/index.ts | 12 |
13 files changed, 171 insertions, 1423 deletions
diff --git a/server/helpers/ffmpeg/codecs.ts b/server/helpers/ffmpeg/codecs.ts new file mode 100644 index 000000000..3bd7db396 --- /dev/null +++ b/server/helpers/ffmpeg/codecs.ts | |||
@@ -0,0 +1,64 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { getAudioStream, getVideoStream } from '@shared/ffmpeg' | ||
3 | import { logger } from '../logger' | ||
4 | import { forceNumber } from '@shared/core-utils' | ||
5 | |||
6 | export async function getVideoStreamCodec (path: string) { | ||
7 | const videoStream = await getVideoStream(path) | ||
8 | if (!videoStream) return '' | ||
9 | |||
10 | const videoCodec = videoStream.codec_tag_string | ||
11 | |||
12 | if (videoCodec === 'vp09') return 'vp09.00.50.08' | ||
13 | if (videoCodec === 'hev1') return 'hev1.1.6.L93.B0' | ||
14 | |||
15 | const baseProfileMatrix = { | ||
16 | avc1: { | ||
17 | High: '6400', | ||
18 | Main: '4D40', | ||
19 | Baseline: '42E0' | ||
20 | }, | ||
21 | av01: { | ||
22 | High: '1', | ||
23 | Main: '0', | ||
24 | Professional: '2' | ||
25 | } | ||
26 | } | ||
27 | |||
28 | let baseProfile = baseProfileMatrix[videoCodec][videoStream.profile] | ||
29 | if (!baseProfile) { | ||
30 | logger.warn('Cannot get video profile codec of %s.', path, { videoStream }) | ||
31 | baseProfile = baseProfileMatrix[videoCodec]['High'] // Fallback | ||
32 | } | ||
33 | |||
34 | if (videoCodec === 'av01') { | ||
35 | let level = videoStream.level.toString() | ||
36 | if (level.length === 1) level = `0${level}` | ||
37 | |||
38 | // Guess the tier indicator and bit depth | ||
39 | return `${videoCodec}.${baseProfile}.${level}M.08` | ||
40 | } | ||
41 | |||
42 | let level = forceNumber(videoStream.level).toString(16) | ||
43 | if (level.length === 1) level = `0${level}` | ||
44 | |||
45 | // Default, h264 codec | ||
46 | return `${videoCodec}.${baseProfile}${level}` | ||
47 | } | ||
48 | |||
49 | export async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) { | ||
50 | const { audioStream } = await getAudioStream(path, existingProbe) | ||
51 | |||
52 | if (!audioStream) return '' | ||
53 | |||
54 | const audioCodecName = audioStream.codec_name | ||
55 | |||
56 | if (audioCodecName === 'opus') return 'opus' | ||
57 | if (audioCodecName === 'vorbis') return 'vorbis' | ||
58 | if (audioCodecName === 'aac') return 'mp4a.40.2' | ||
59 | if (audioCodecName === 'mp3') return 'mp4a.40.34' | ||
60 | |||
61 | logger.warn('Cannot get audio codec of %s.', path, { audioStream }) | ||
62 | |||
63 | return 'mp4a.40.2' // Fallback | ||
64 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-commons.ts b/server/helpers/ffmpeg/ffmpeg-commons.ts deleted file mode 100644 index 3906a2089..000000000 --- a/server/helpers/ffmpeg/ffmpeg-commons.ts +++ /dev/null | |||
@@ -1,114 +0,0 @@ | |||
1 | import { Job } from 'bullmq' | ||
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?.[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.updateProgress(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 deleted file mode 100644 index 02c5ea8de..000000000 --- a/server/helpers/ffmpeg/ffmpeg-edition.ts +++ /dev/null | |||
@@ -1,258 +0,0 @@ | |||
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 { 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 | availableEncoders: AvailableEncoders | ||
18 | profile: string | ||
19 | }) { | ||
20 | const { inputPath, outputPath, availableEncoders, profile } = options | ||
21 | |||
22 | logger.debug('Will cut the video.', { options, ...lTags() }) | ||
23 | |||
24 | const mainProbe = await ffprobePromise(inputPath) | ||
25 | const fps = await getVideoStreamFPS(inputPath, mainProbe) | ||
26 | const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) | ||
27 | |||
28 | let command = getFFmpeg(inputPath, 'vod') | ||
29 | .output(outputPath) | ||
30 | |||
31 | command = await presetVOD({ | ||
32 | command, | ||
33 | input: inputPath, | ||
34 | availableEncoders, | ||
35 | profile, | ||
36 | resolution, | ||
37 | fps, | ||
38 | canCopyAudio: false, | ||
39 | canCopyVideo: false | ||
40 | }) | ||
41 | |||
42 | if (options.start) { | ||
43 | command.outputOption('-ss ' + options.start) | ||
44 | } | ||
45 | |||
46 | if (options.end) { | ||
47 | command.outputOption('-to ' + options.end) | ||
48 | } | ||
49 | |||
50 | await runCommand({ command }) | ||
51 | } | ||
52 | |||
53 | async function addWatermark (options: { | ||
54 | inputPath: string | ||
55 | watermarkPath: string | ||
56 | outputPath: string | ||
57 | |||
58 | availableEncoders: AvailableEncoders | ||
59 | profile: string | ||
60 | }) { | ||
61 | const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options | ||
62 | |||
63 | logger.debug('Will add watermark to the video.', { options, ...lTags() }) | ||
64 | |||
65 | const videoProbe = await ffprobePromise(inputPath) | ||
66 | const fps = await getVideoStreamFPS(inputPath, videoProbe) | ||
67 | const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe) | ||
68 | |||
69 | let command = getFFmpeg(inputPath, 'vod') | ||
70 | .output(outputPath) | ||
71 | command.input(watermarkPath) | ||
72 | |||
73 | command = await presetVOD({ | ||
74 | command, | ||
75 | input: inputPath, | ||
76 | availableEncoders, | ||
77 | profile, | ||
78 | resolution, | ||
79 | fps, | ||
80 | canCopyAudio: true, | ||
81 | canCopyVideo: false | ||
82 | }) | ||
83 | |||
84 | const complexFilter: FilterSpecification[] = [ | ||
85 | // Scale watermark | ||
86 | { | ||
87 | inputs: [ '[1]', '[0]' ], | ||
88 | filter: 'scale2ref', | ||
89 | options: { | ||
90 | w: 'oh*mdar', | ||
91 | h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}` | ||
92 | }, | ||
93 | outputs: [ '[watermark]', '[video]' ] | ||
94 | }, | ||
95 | |||
96 | { | ||
97 | inputs: [ '[video]', '[watermark]' ], | ||
98 | filter: 'overlay', | ||
99 | options: { | ||
100 | x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`, | ||
101 | y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}` | ||
102 | } | ||
103 | } | ||
104 | ] | ||
105 | |||
106 | command.complexFilter(complexFilter) | ||
107 | |||
108 | await runCommand({ command }) | ||
109 | } | ||
110 | |||
111 | async function addIntroOutro (options: { | ||
112 | inputPath: string | ||
113 | introOutroPath: string | ||
114 | outputPath: string | ||
115 | type: 'intro' | 'outro' | ||
116 | |||
117 | availableEncoders: AvailableEncoders | ||
118 | profile: string | ||
119 | }) { | ||
120 | const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options | ||
121 | |||
122 | logger.debug('Will add intro/outro to the video.', { options, ...lTags() }) | ||
123 | |||
124 | const mainProbe = await ffprobePromise(inputPath) | ||
125 | const fps = await getVideoStreamFPS(inputPath, mainProbe) | ||
126 | const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe) | ||
127 | const mainHasAudio = await hasAudioStream(inputPath, mainProbe) | ||
128 | |||
129 | const introOutroProbe = await ffprobePromise(introOutroPath) | ||
130 | const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe) | ||
131 | |||
132 | let command = getFFmpeg(inputPath, 'vod') | ||
133 | .output(outputPath) | ||
134 | |||
135 | command.input(introOutroPath) | ||
136 | |||
137 | if (!introOutroHasAudio && mainHasAudio) { | ||
138 | const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe) | ||
139 | |||
140 | command.input('anullsrc') | ||
141 | command.withInputFormat('lavfi') | ||
142 | command.withInputOption('-t ' + duration) | ||
143 | } | ||
144 | |||
145 | command = await presetVOD({ | ||
146 | command, | ||
147 | input: inputPath, | ||
148 | availableEncoders, | ||
149 | profile, | ||
150 | resolution, | ||
151 | fps, | ||
152 | canCopyAudio: false, | ||
153 | canCopyVideo: false | ||
154 | }) | ||
155 | |||
156 | // Add black background to correctly scale intro/outro with padding | ||
157 | const complexFilter: FilterSpecification[] = [ | ||
158 | { | ||
159 | inputs: [ '1', '0' ], | ||
160 | filter: 'scale2ref', | ||
161 | options: { | ||
162 | w: 'iw', | ||
163 | h: `ih` | ||
164 | }, | ||
165 | outputs: [ 'intro-outro', 'main' ] | ||
166 | }, | ||
167 | { | ||
168 | inputs: [ 'intro-outro', 'main' ], | ||
169 | filter: 'scale2ref', | ||
170 | options: { | ||
171 | w: 'iw', | ||
172 | h: `ih` | ||
173 | }, | ||
174 | outputs: [ 'to-scale', 'main' ] | ||
175 | }, | ||
176 | { | ||
177 | inputs: 'to-scale', | ||
178 | filter: 'drawbox', | ||
179 | options: { | ||
180 | t: 'fill' | ||
181 | }, | ||
182 | outputs: [ 'to-scale-bg' ] | ||
183 | }, | ||
184 | { | ||
185 | inputs: [ '1', 'to-scale-bg' ], | ||
186 | filter: 'scale2ref', | ||
187 | options: { | ||
188 | w: 'iw', | ||
189 | h: 'ih', | ||
190 | force_original_aspect_ratio: 'decrease', | ||
191 | flags: 'spline' | ||
192 | }, | ||
193 | outputs: [ 'to-scale', 'to-scale-bg' ] | ||
194 | }, | ||
195 | { | ||
196 | inputs: [ 'to-scale-bg', 'to-scale' ], | ||
197 | filter: 'overlay', | ||
198 | options: { | ||
199 | x: '(main_w - overlay_w)/2', | ||
200 | y: '(main_h - overlay_h)/2' | ||
201 | }, | ||
202 | outputs: 'intro-outro-resized' | ||
203 | } | ||
204 | ] | ||
205 | |||
206 | const concatFilter = { | ||
207 | inputs: [], | ||
208 | filter: 'concat', | ||
209 | options: { | ||
210 | n: 2, | ||
211 | v: 1, | ||
212 | unsafe: 1 | ||
213 | }, | ||
214 | outputs: [ 'v' ] | ||
215 | } | ||
216 | |||
217 | const introOutroFilterInputs = [ 'intro-outro-resized' ] | ||
218 | const mainFilterInputs = [ 'main' ] | ||
219 | |||
220 | if (mainHasAudio) { | ||
221 | mainFilterInputs.push('0:a') | ||
222 | |||
223 | if (introOutroHasAudio) { | ||
224 | introOutroFilterInputs.push('1:a') | ||
225 | } else { | ||
226 | // Silent input | ||
227 | introOutroFilterInputs.push('2:a') | ||
228 | } | ||
229 | } | ||
230 | |||
231 | if (type === 'intro') { | ||
232 | concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ] | ||
233 | } else { | ||
234 | concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ] | ||
235 | } | ||
236 | |||
237 | if (mainHasAudio) { | ||
238 | concatFilter.options['a'] = 1 | ||
239 | concatFilter.outputs.push('a') | ||
240 | |||
241 | command.outputOption('-map [a]') | ||
242 | } | ||
243 | |||
244 | command.outputOption('-map [v]') | ||
245 | |||
246 | complexFilter.push(concatFilter) | ||
247 | command.complexFilter(complexFilter) | ||
248 | |||
249 | await runCommand({ command }) | ||
250 | } | ||
251 | |||
252 | // --------------------------------------------------------------------------- | ||
253 | |||
254 | export { | ||
255 | cutVideo, | ||
256 | addIntroOutro, | ||
257 | addWatermark | ||
258 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-encoders.ts b/server/helpers/ffmpeg/ffmpeg-encoders.ts deleted file mode 100644 index 5bd80ba05..000000000 --- a/server/helpers/ffmpeg/ffmpeg-encoders.ts +++ /dev/null | |||
@@ -1,116 +0,0 @@ | |||
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-image.ts b/server/helpers/ffmpeg/ffmpeg-image.ts new file mode 100644 index 000000000..0bb0ff2c0 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-image.ts | |||
@@ -0,0 +1,14 @@ | |||
1 | import { FFmpegImage } from '@shared/ffmpeg' | ||
2 | import { getFFmpegCommandWrapperOptions } from './ffmpeg-options' | ||
3 | |||
4 | export function processGIF (options: Parameters<FFmpegImage['processGIF']>[0]) { | ||
5 | return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).processGIF(options) | ||
6 | } | ||
7 | |||
8 | export function generateThumbnailFromVideo (options: Parameters<FFmpegImage['generateThumbnailFromVideo']>[0]) { | ||
9 | return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).generateThumbnailFromVideo(options) | ||
10 | } | ||
11 | |||
12 | export function convertWebPToJPG (options: Parameters<FFmpegImage['convertWebPToJPG']>[0]) { | ||
13 | return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).convertWebPToJPG(options) | ||
14 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-images.ts b/server/helpers/ffmpeg/ffmpeg-images.ts deleted file mode 100644 index 7f64c6d0a..000000000 --- a/server/helpers/ffmpeg/ffmpeg-images.ts +++ /dev/null | |||
@@ -1,46 +0,0 @@ | |||
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 deleted file mode 100644 index 379d7b1ad..000000000 --- a/server/helpers/ffmpeg/ffmpeg-live.ts +++ /dev/null | |||
@@ -1,204 +0,0 @@ | |||
1 | import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { join } from 'path' | ||
3 | import { VIDEO_LIVE } from '@server/initializers/constants' | ||
4 | import { AvailableEncoders, LiveVideoLatencyMode } 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 | latencyMode: LiveVideoLatencyMode | ||
19 | |||
20 | resolutions: number[] | ||
21 | |||
22 | // Input information | ||
23 | fps: number | ||
24 | bitrate: number | ||
25 | ratio: number | ||
26 | hasAudio: boolean | ||
27 | |||
28 | availableEncoders: AvailableEncoders | ||
29 | profile: string | ||
30 | }) { | ||
31 | const { | ||
32 | inputUrl, | ||
33 | outPath, | ||
34 | resolutions, | ||
35 | fps, | ||
36 | bitrate, | ||
37 | availableEncoders, | ||
38 | profile, | ||
39 | masterPlaylistName, | ||
40 | ratio, | ||
41 | latencyMode, | ||
42 | hasAudio | ||
43 | } = options | ||
44 | |||
45 | const command = getFFmpeg(inputUrl, 'live') | ||
46 | |||
47 | const varStreamMap: string[] = [] | ||
48 | |||
49 | const complexFilter: FilterSpecification[] = [ | ||
50 | { | ||
51 | inputs: '[v:0]', | ||
52 | filter: 'split', | ||
53 | options: resolutions.length, | ||
54 | outputs: resolutions.map(r => `vtemp${r}`) | ||
55 | } | ||
56 | ] | ||
57 | |||
58 | command.outputOption('-sc_threshold 0') | ||
59 | |||
60 | addDefaultEncoderGlobalParams(command) | ||
61 | |||
62 | for (let i = 0; i < resolutions.length; i++) { | ||
63 | const streamMap: string[] = [] | ||
64 | const resolution = resolutions[i] | ||
65 | const resolutionFPS = computeFPS(fps, resolution) | ||
66 | |||
67 | const baseEncoderBuilderParams = { | ||
68 | input: inputUrl, | ||
69 | |||
70 | availableEncoders, | ||
71 | profile, | ||
72 | |||
73 | canCopyAudio: true, | ||
74 | canCopyVideo: true, | ||
75 | |||
76 | inputBitrate: bitrate, | ||
77 | inputRatio: ratio, | ||
78 | |||
79 | resolution, | ||
80 | fps: resolutionFPS, | ||
81 | |||
82 | streamNum: i, | ||
83 | videoType: 'live' as 'live' | ||
84 | } | ||
85 | |||
86 | { | ||
87 | const streamType: StreamType = 'video' | ||
88 | const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) | ||
89 | if (!builderResult) { | ||
90 | throw new Error('No available live video encoder found') | ||
91 | } | ||
92 | |||
93 | command.outputOption(`-map [vout${resolution}]`) | ||
94 | |||
95 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) | ||
96 | |||
97 | logger.debug( | ||
98 | 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile, | ||
99 | { builderResult, fps: resolutionFPS, resolution, ...lTags() } | ||
100 | ) | ||
101 | |||
102 | command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`) | ||
103 | applyEncoderOptions(command, builderResult.result) | ||
104 | |||
105 | complexFilter.push({ | ||
106 | inputs: `vtemp${resolution}`, | ||
107 | filter: getScaleFilter(builderResult.result), | ||
108 | options: `w=-2:h=${resolution}`, | ||
109 | outputs: `vout${resolution}` | ||
110 | }) | ||
111 | |||
112 | streamMap.push(`v:${i}`) | ||
113 | } | ||
114 | |||
115 | if (hasAudio) { | ||
116 | const streamType: StreamType = 'audio' | ||
117 | const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType }) | ||
118 | if (!builderResult) { | ||
119 | throw new Error('No available live audio encoder found') | ||
120 | } | ||
121 | |||
122 | command.outputOption('-map a:0') | ||
123 | |||
124 | addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i }) | ||
125 | |||
126 | logger.debug( | ||
127 | 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile, | ||
128 | { builderResult, fps: resolutionFPS, resolution, ...lTags() } | ||
129 | ) | ||
130 | |||
131 | command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`) | ||
132 | applyEncoderOptions(command, builderResult.result) | ||
133 | |||
134 | streamMap.push(`a:${i}`) | ||
135 | } | ||
136 | |||
137 | varStreamMap.push(streamMap.join(',')) | ||
138 | } | ||
139 | |||
140 | command.complexFilter(complexFilter) | ||
141 | |||
142 | addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode }) | ||
143 | |||
144 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) | ||
145 | |||
146 | return command | ||
147 | } | ||
148 | |||
149 | function getLiveMuxingCommand (options: { | ||
150 | inputUrl: string | ||
151 | outPath: string | ||
152 | masterPlaylistName: string | ||
153 | latencyMode: LiveVideoLatencyMode | ||
154 | }) { | ||
155 | const { inputUrl, outPath, masterPlaylistName, latencyMode } = options | ||
156 | |||
157 | const command = getFFmpeg(inputUrl, 'live') | ||
158 | |||
159 | command.outputOption('-c:v copy') | ||
160 | command.outputOption('-c:a copy') | ||
161 | command.outputOption('-map 0:a?') | ||
162 | command.outputOption('-map 0:v?') | ||
163 | |||
164 | addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode }) | ||
165 | |||
166 | return command | ||
167 | } | ||
168 | |||
169 | function getLiveSegmentTime (latencyMode: LiveVideoLatencyMode) { | ||
170 | if (latencyMode === LiveVideoLatencyMode.SMALL_LATENCY) { | ||
171 | return VIDEO_LIVE.SEGMENT_TIME_SECONDS.SMALL_LATENCY | ||
172 | } | ||
173 | |||
174 | return VIDEO_LIVE.SEGMENT_TIME_SECONDS.DEFAULT_LATENCY | ||
175 | } | ||
176 | |||
177 | // --------------------------------------------------------------------------- | ||
178 | |||
179 | export { | ||
180 | getLiveSegmentTime, | ||
181 | |||
182 | getLiveTranscodingCommand, | ||
183 | getLiveMuxingCommand | ||
184 | } | ||
185 | |||
186 | // --------------------------------------------------------------------------- | ||
187 | |||
188 | function addDefaultLiveHLSParams (options: { | ||
189 | command: FfmpegCommand | ||
190 | outPath: string | ||
191 | masterPlaylistName: string | ||
192 | latencyMode: LiveVideoLatencyMode | ||
193 | }) { | ||
194 | const { command, outPath, masterPlaylistName, latencyMode } = options | ||
195 | |||
196 | command.outputOption('-hls_time ' + getLiveSegmentTime(latencyMode)) | ||
197 | command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) | ||
198 | command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time') | ||
199 | command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) | ||
200 | command.outputOption('-master_pl_name ' + masterPlaylistName) | ||
201 | command.outputOption(`-f hls`) | ||
202 | |||
203 | command.output(join(outPath, '%v.m3u8')) | ||
204 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-options.ts b/server/helpers/ffmpeg/ffmpeg-options.ts new file mode 100644 index 000000000..db6350d39 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-options.ts | |||
@@ -0,0 +1,45 @@ | |||
1 | import { logger } from '@server/helpers/logger' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { FFMPEG_NICE } from '@server/initializers/constants' | ||
4 | import { FFmpegCommandWrapperOptions } from '@shared/ffmpeg' | ||
5 | import { AvailableEncoders } from '@shared/models' | ||
6 | |||
7 | type CommandType = 'live' | 'vod' | 'thumbnail' | ||
8 | |||
9 | export function getFFmpegCommandWrapperOptions (type: CommandType, availableEncoders?: AvailableEncoders): FFmpegCommandWrapperOptions { | ||
10 | return { | ||
11 | availableEncoders, | ||
12 | profile: getProfile(type), | ||
13 | |||
14 | niceness: FFMPEG_NICE[type], | ||
15 | tmpDirectory: CONFIG.STORAGE.TMP_DIR, | ||
16 | threads: getThreads(type), | ||
17 | |||
18 | logger: { | ||
19 | debug: logger.debug.bind(logger), | ||
20 | info: logger.info.bind(logger), | ||
21 | warn: logger.warn.bind(logger), | ||
22 | error: logger.error.bind(logger) | ||
23 | }, | ||
24 | lTags: { tags: [ 'ffmpeg' ] } | ||
25 | } | ||
26 | } | ||
27 | |||
28 | // --------------------------------------------------------------------------- | ||
29 | // Private | ||
30 | // --------------------------------------------------------------------------- | ||
31 | |||
32 | function getThreads (type: CommandType) { | ||
33 | if (type === 'live') return CONFIG.LIVE.TRANSCODING.THREADS | ||
34 | if (type === 'vod') return CONFIG.TRANSCODING.THREADS | ||
35 | |||
36 | // Auto | ||
37 | return 0 | ||
38 | } | ||
39 | |||
40 | function getProfile (type: CommandType) { | ||
41 | if (type === 'live') return CONFIG.LIVE.TRANSCODING.PROFILE | ||
42 | if (type === 'vod') return CONFIG.TRANSCODING.PROFILE | ||
43 | |||
44 | return undefined | ||
45 | } | ||
diff --git a/server/helpers/ffmpeg/ffmpeg-presets.ts b/server/helpers/ffmpeg/ffmpeg-presets.ts deleted file mode 100644 index d1160a4a2..000000000 --- a/server/helpers/ffmpeg/ffmpeg-presets.ts +++ /dev/null | |||
@@ -1,156 +0,0 @@ | |||
1 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { pick } from '@shared/core-utils' | ||
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 deleted file mode 100644 index d84703eb9..000000000 --- a/server/helpers/ffmpeg/ffmpeg-vod.ts +++ /dev/null | |||
@@ -1,267 +0,0 @@ | |||
1 | import { MutexInterface } from 'async-mutex' | ||
2 | import { Job } from 'bullmq' | ||
3 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
4 | import { readFile, writeFile } from 'fs-extra' | ||
5 | import { dirname } from 'path' | ||
6 | import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' | ||
7 | import { pick } from '@shared/core-utils' | ||
8 | import { AvailableEncoders, VideoResolution } from '@shared/models' | ||
9 | import { logger, loggerTagsFactory } from '../logger' | ||
10 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | ||
11 | import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets' | ||
12 | import { computeFPS, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS } from './ffprobe-utils' | ||
13 | |||
14 | const lTags = loggerTagsFactory('ffmpeg') | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
19 | |||
20 | interface BaseTranscodeVODOptions { | ||
21 | type: TranscodeVODOptionsType | ||
22 | |||
23 | inputPath: string | ||
24 | outputPath: string | ||
25 | |||
26 | // Will be released after the ffmpeg started | ||
27 | // To prevent a bug where the input file does not exist anymore when running ffmpeg | ||
28 | inputFileMutexReleaser: MutexInterface.Releaser | ||
29 | |||
30 | availableEncoders: AvailableEncoders | ||
31 | profile: string | ||
32 | |||
33 | resolution: number | ||
34 | |||
35 | job?: Job | ||
36 | } | ||
37 | |||
38 | interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | ||
39 | type: 'hls' | ||
40 | copyCodecs: boolean | ||
41 | hlsPlaylist: { | ||
42 | videoFilename: string | ||
43 | } | ||
44 | } | ||
45 | |||
46 | interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | ||
47 | type: 'hls-from-ts' | ||
48 | |||
49 | isAAC: boolean | ||
50 | |||
51 | hlsPlaylist: { | ||
52 | videoFilename: string | ||
53 | } | ||
54 | } | ||
55 | |||
56 | interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | ||
57 | type: 'quick-transcode' | ||
58 | } | ||
59 | |||
60 | interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | ||
61 | type: 'video' | ||
62 | } | ||
63 | |||
64 | interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
65 | type: 'merge-audio' | ||
66 | audioPath: string | ||
67 | } | ||
68 | |||
69 | interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
70 | type: 'only-audio' | ||
71 | } | ||
72 | |||
73 | type TranscodeVODOptions = | ||
74 | HLSTranscodeOptions | ||
75 | | HLSFromTSTranscodeOptions | ||
76 | | VideoTranscodeOptions | ||
77 | | MergeAudioTranscodeOptions | ||
78 | | OnlyAudioTranscodeOptions | ||
79 | | QuickTranscodeOptions | ||
80 | |||
81 | // --------------------------------------------------------------------------- | ||
82 | |||
83 | const builders: { | ||
84 | [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand | ||
85 | } = { | ||
86 | 'quick-transcode': buildQuickTranscodeCommand, | ||
87 | 'hls': buildHLSVODCommand, | ||
88 | 'hls-from-ts': buildHLSVODFromTSCommand, | ||
89 | 'merge-audio': buildAudioMergeCommand, | ||
90 | 'only-audio': buildOnlyAudioCommand, | ||
91 | 'video': buildVODCommand | ||
92 | } | ||
93 | |||
94 | async function transcodeVOD (options: TranscodeVODOptions) { | ||
95 | logger.debug('Will run transcode.', { options, ...lTags() }) | ||
96 | |||
97 | let command = getFFmpeg(options.inputPath, 'vod') | ||
98 | .output(options.outputPath) | ||
99 | |||
100 | command = await builders[options.type](command, options) | ||
101 | |||
102 | command.on('start', () => { | ||
103 | setTimeout(() => { | ||
104 | options.inputFileMutexReleaser() | ||
105 | }, 1000) | ||
106 | }) | ||
107 | |||
108 | await runCommand({ command, job: options.job }) | ||
109 | |||
110 | await fixHLSPlaylistIfNeeded(options) | ||
111 | } | ||
112 | |||
113 | // --------------------------------------------------------------------------- | ||
114 | |||
115 | export { | ||
116 | transcodeVOD, | ||
117 | |||
118 | buildVODCommand, | ||
119 | |||
120 | TranscodeVODOptions, | ||
121 | TranscodeVODOptionsType | ||
122 | } | ||
123 | |||
124 | // --------------------------------------------------------------------------- | ||
125 | |||
126 | async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) { | ||
127 | const probe = await ffprobePromise(options.inputPath) | ||
128 | |||
129 | let fps = await getVideoStreamFPS(options.inputPath, probe) | ||
130 | fps = computeFPS(fps, options.resolution) | ||
131 | |||
132 | let scaleFilterValue: string | ||
133 | |||
134 | if (options.resolution !== undefined) { | ||
135 | const videoStreamInfo = await getVideoStreamDimensionsInfo(options.inputPath, probe) | ||
136 | |||
137 | scaleFilterValue = videoStreamInfo?.isPortraitMode === true | ||
138 | ? `w=${options.resolution}:h=-2` | ||
139 | : `w=-2:h=${options.resolution}` | ||
140 | } | ||
141 | |||
142 | command = await presetVOD({ | ||
143 | ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]), | ||
144 | |||
145 | command, | ||
146 | input: options.inputPath, | ||
147 | canCopyAudio: true, | ||
148 | canCopyVideo: true, | ||
149 | fps, | ||
150 | scaleFilterValue | ||
151 | }) | ||
152 | |||
153 | return command | ||
154 | } | ||
155 | |||
156 | function buildQuickTranscodeCommand (command: FfmpegCommand) { | ||
157 | command = presetCopy(command) | ||
158 | |||
159 | command = command.outputOption('-map_metadata -1') // strip all metadata | ||
160 | .outputOption('-movflags faststart') | ||
161 | |||
162 | return command | ||
163 | } | ||
164 | |||
165 | // --------------------------------------------------------------------------- | ||
166 | // Audio transcoding | ||
167 | // --------------------------------------------------------------------------- | ||
168 | |||
169 | async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) { | ||
170 | command = command.loop(undefined) | ||
171 | |||
172 | const scaleFilterValue = getMergeAudioScaleFilterValue() | ||
173 | command = await presetVOD({ | ||
174 | ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]), | ||
175 | |||
176 | command, | ||
177 | input: options.audioPath, | ||
178 | canCopyAudio: true, | ||
179 | canCopyVideo: true, | ||
180 | fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE, | ||
181 | scaleFilterValue | ||
182 | }) | ||
183 | |||
184 | command.outputOption('-preset:v veryfast') | ||
185 | |||
186 | command = command.input(options.audioPath) | ||
187 | .outputOption('-tune stillimage') | ||
188 | .outputOption('-shortest') | ||
189 | |||
190 | return command | ||
191 | } | ||
192 | |||
193 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | ||
194 | command = presetOnlyAudio(command) | ||
195 | |||
196 | return command | ||
197 | } | ||
198 | |||
199 | // --------------------------------------------------------------------------- | ||
200 | // HLS transcoding | ||
201 | // --------------------------------------------------------------------------- | ||
202 | |||
203 | async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) { | ||
204 | const videoPath = getHLSVideoPath(options) | ||
205 | |||
206 | if (options.copyCodecs) command = presetCopy(command) | ||
207 | else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) | ||
208 | else command = await buildVODCommand(command, options) | ||
209 | |||
210 | addCommonHLSVODCommandOptions(command, videoPath) | ||
211 | |||
212 | return command | ||
213 | } | ||
214 | |||
215 | function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) { | ||
216 | const videoPath = getHLSVideoPath(options) | ||
217 | |||
218 | command.outputOption('-c copy') | ||
219 | |||
220 | if (options.isAAC) { | ||
221 | // Required for example when copying an AAC stream from an MPEG-TS | ||
222 | // Since it's a bitstream filter, we don't need to reencode the audio | ||
223 | command.outputOption('-bsf:a aac_adtstoasc') | ||
224 | } | ||
225 | |||
226 | addCommonHLSVODCommandOptions(command, videoPath) | ||
227 | |||
228 | return command | ||
229 | } | ||
230 | |||
231 | function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) { | ||
232 | return command.outputOption('-hls_time 4') | ||
233 | .outputOption('-hls_list_size 0') | ||
234 | .outputOption('-hls_playlist_type vod') | ||
235 | .outputOption('-hls_segment_filename ' + outputPath) | ||
236 | .outputOption('-hls_segment_type fmp4') | ||
237 | .outputOption('-f hls') | ||
238 | .outputOption('-hls_flags single_file') | ||
239 | } | ||
240 | |||
241 | async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) { | ||
242 | if (options.type !== 'hls' && options.type !== 'hls-from-ts') return | ||
243 | |||
244 | const fileContent = await readFile(options.outputPath) | ||
245 | |||
246 | const videoFileName = options.hlsPlaylist.videoFilename | ||
247 | const videoFilePath = getHLSVideoPath(options) | ||
248 | |||
249 | // Fix wrong mapping with some ffmpeg versions | ||
250 | const newContent = fileContent.toString() | ||
251 | .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) | ||
252 | |||
253 | await writeFile(options.outputPath, newContent) | ||
254 | } | ||
255 | |||
256 | // --------------------------------------------------------------------------- | ||
257 | // Helpers | ||
258 | // --------------------------------------------------------------------------- | ||
259 | |||
260 | function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { | ||
261 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | ||
262 | } | ||
263 | |||
264 | // Avoid "height not divisible by 2" error | ||
265 | function getMergeAudioScaleFilterValue () { | ||
266 | return 'trunc(iw/2)*2:trunc(ih/2)*2' | ||
267 | } | ||
diff --git a/server/helpers/ffmpeg/ffprobe-utils.ts b/server/helpers/ffmpeg/ffprobe-utils.ts deleted file mode 100644 index fb270b3cb..000000000 --- a/server/helpers/ffmpeg/ffprobe-utils.ts +++ /dev/null | |||
@@ -1,254 +0,0 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { getMaxBitrate } from '@shared/core-utils' | ||
3 | import { | ||
4 | buildFileMetadata, | ||
5 | ffprobePromise, | ||
6 | getAudioStream, | ||
7 | getMaxAudioBitrate, | ||
8 | getVideoStream, | ||
9 | getVideoStreamBitrate, | ||
10 | getVideoStreamDimensionsInfo, | ||
11 | getVideoStreamDuration, | ||
12 | getVideoStreamFPS, | ||
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 { toEven } from '../core-utils' | ||
19 | import { logger } from '../logger' | ||
20 | |||
21 | /** | ||
22 | * | ||
23 | * Helpers to run ffprobe and extract data from the JSON output | ||
24 | * | ||
25 | */ | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | // Codecs | ||
29 | // --------------------------------------------------------------------------- | ||
30 | |||
31 | async function getVideoStreamCodec (path: string) { | ||
32 | const videoStream = await getVideoStream(path) | ||
33 | if (!videoStream) return '' | ||
34 | |||
35 | const videoCodec = videoStream.codec_tag_string | ||
36 | |||
37 | if (videoCodec === 'vp09') return 'vp09.00.50.08' | ||
38 | if (videoCodec === 'hev1') return 'hev1.1.6.L93.B0' | ||
39 | |||
40 | const baseProfileMatrix = { | ||
41 | avc1: { | ||
42 | High: '6400', | ||
43 | Main: '4D40', | ||
44 | Baseline: '42E0' | ||
45 | }, | ||
46 | av01: { | ||
47 | High: '1', | ||
48 | Main: '0', | ||
49 | Professional: '2' | ||
50 | } | ||
51 | } | ||
52 | |||
53 | let baseProfile = baseProfileMatrix[videoCodec][videoStream.profile] | ||
54 | if (!baseProfile) { | ||
55 | logger.warn('Cannot get video profile codec of %s.', path, { videoStream }) | ||
56 | baseProfile = baseProfileMatrix[videoCodec]['High'] // Fallback | ||
57 | } | ||
58 | |||
59 | if (videoCodec === 'av01') { | ||
60 | let level = videoStream.level.toString() | ||
61 | if (level.length === 1) level = `0${level}` | ||
62 | |||
63 | // Guess the tier indicator and bit depth | ||
64 | return `${videoCodec}.${baseProfile}.${level}M.08` | ||
65 | } | ||
66 | |||
67 | let level = videoStream.level.toString(16) | ||
68 | if (level.length === 1) level = `0${level}` | ||
69 | |||
70 | // Default, h264 codec | ||
71 | return `${videoCodec}.${baseProfile}${level}` | ||
72 | } | ||
73 | |||
74 | async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) { | ||
75 | const { audioStream } = await getAudioStream(path, existingProbe) | ||
76 | |||
77 | if (!audioStream) return '' | ||
78 | |||
79 | const audioCodecName = audioStream.codec_name | ||
80 | |||
81 | if (audioCodecName === 'opus') return 'opus' | ||
82 | if (audioCodecName === 'vorbis') return 'vorbis' | ||
83 | if (audioCodecName === 'aac') return 'mp4a.40.2' | ||
84 | if (audioCodecName === 'mp3') return 'mp4a.40.34' | ||
85 | |||
86 | logger.warn('Cannot get audio codec of %s.', path, { audioStream }) | ||
87 | |||
88 | return 'mp4a.40.2' // Fallback | ||
89 | } | ||
90 | |||
91 | // --------------------------------------------------------------------------- | ||
92 | // Resolutions | ||
93 | // --------------------------------------------------------------------------- | ||
94 | |||
95 | function computeResolutionsToTranscode (options: { | ||
96 | input: number | ||
97 | type: 'vod' | 'live' | ||
98 | includeInput: boolean | ||
99 | strictLower: boolean | ||
100 | hasAudio: boolean | ||
101 | }) { | ||
102 | const { input, type, includeInput, strictLower, hasAudio } = options | ||
103 | |||
104 | const configResolutions = type === 'vod' | ||
105 | ? CONFIG.TRANSCODING.RESOLUTIONS | ||
106 | : CONFIG.LIVE.TRANSCODING.RESOLUTIONS | ||
107 | |||
108 | const resolutionsEnabled = new Set<number>() | ||
109 | |||
110 | // Put in the order we want to proceed jobs | ||
111 | const availableResolutions: VideoResolution[] = [ | ||
112 | VideoResolution.H_NOVIDEO, | ||
113 | VideoResolution.H_480P, | ||
114 | VideoResolution.H_360P, | ||
115 | VideoResolution.H_720P, | ||
116 | VideoResolution.H_240P, | ||
117 | VideoResolution.H_144P, | ||
118 | VideoResolution.H_1080P, | ||
119 | VideoResolution.H_1440P, | ||
120 | VideoResolution.H_4K | ||
121 | ] | ||
122 | |||
123 | for (const resolution of availableResolutions) { | ||
124 | // Resolution not enabled | ||
125 | if (configResolutions[resolution + 'p'] !== true) continue | ||
126 | // Too big resolution for input file | ||
127 | if (input < resolution) continue | ||
128 | // We only want lower resolutions than input file | ||
129 | if (strictLower && input === resolution) continue | ||
130 | // Audio resolutio but no audio in the video | ||
131 | if (resolution === VideoResolution.H_NOVIDEO && !hasAudio) continue | ||
132 | |||
133 | resolutionsEnabled.add(resolution) | ||
134 | } | ||
135 | |||
136 | if (includeInput) { | ||
137 | // Always use an even resolution to avoid issues with ffmpeg | ||
138 | resolutionsEnabled.add(toEven(input)) | ||
139 | } | ||
140 | |||
141 | return Array.from(resolutionsEnabled) | ||
142 | } | ||
143 | |||
144 | // --------------------------------------------------------------------------- | ||
145 | // Can quick transcode | ||
146 | // --------------------------------------------------------------------------- | ||
147 | |||
148 | async function canDoQuickTranscode (path: string): Promise<boolean> { | ||
149 | if (CONFIG.TRANSCODING.PROFILE !== 'default') return false | ||
150 | |||
151 | const probe = await ffprobePromise(path) | ||
152 | |||
153 | return await canDoQuickVideoTranscode(path, probe) && | ||
154 | await canDoQuickAudioTranscode(path, probe) | ||
155 | } | ||
156 | |||
157 | async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> { | ||
158 | const parsedAudio = await getAudioStream(path, probe) | ||
159 | |||
160 | if (!parsedAudio.audioStream) return true | ||
161 | |||
162 | if (parsedAudio.audioStream['codec_name'] !== 'aac') return false | ||
163 | |||
164 | const audioBitrate = parsedAudio.bitrate | ||
165 | if (!audioBitrate) return false | ||
166 | |||
167 | const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate) | ||
168 | if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false | ||
169 | |||
170 | const channelLayout = parsedAudio.audioStream['channel_layout'] | ||
171 | // Causes playback issues with Chrome | ||
172 | if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false | ||
173 | |||
174 | return true | ||
175 | } | ||
176 | |||
177 | async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { | ||
178 | const videoStream = await getVideoStream(path, probe) | ||
179 | const fps = await getVideoStreamFPS(path, probe) | ||
180 | const bitRate = await getVideoStreamBitrate(path, probe) | ||
181 | const resolutionData = await getVideoStreamDimensionsInfo(path, probe) | ||
182 | |||
183 | // If ffprobe did not manage to guess the bitrate | ||
184 | if (!bitRate) return false | ||
185 | |||
186 | // check video params | ||
187 | if (!videoStream) return false | ||
188 | if (videoStream['codec_name'] !== 'h264') return false | ||
189 | if (videoStream['pix_fmt'] !== 'yuv420p') return false | ||
190 | if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false | ||
191 | if (bitRate > getMaxBitrate({ ...resolutionData, fps })) return false | ||
192 | |||
193 | return true | ||
194 | } | ||
195 | |||
196 | // --------------------------------------------------------------------------- | ||
197 | // Framerate | ||
198 | // --------------------------------------------------------------------------- | ||
199 | |||
200 | function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { | ||
201 | return VIDEO_TRANSCODING_FPS[type].slice(0) | ||
202 | .sort((a, b) => fps % a - fps % b)[0] | ||
203 | } | ||
204 | |||
205 | function computeFPS (fpsArg: number, resolution: VideoResolution) { | ||
206 | let fps = fpsArg | ||
207 | |||
208 | if ( | ||
209 | // On small/medium resolutions, limit FPS | ||
210 | resolution !== undefined && | ||
211 | resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | ||
212 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | ||
213 | ) { | ||
214 | // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value | ||
215 | fps = getClosestFramerateStandard(fps, 'STANDARD') | ||
216 | } | ||
217 | |||
218 | // Hard FPS limits | ||
219 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD') | ||
220 | |||
221 | if (fps < VIDEO_TRANSCODING_FPS.MIN) { | ||
222 | throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${VIDEO_TRANSCODING_FPS.MIN}`) | ||
223 | } | ||
224 | |||
225 | return fps | ||
226 | } | ||
227 | |||
228 | // --------------------------------------------------------------------------- | ||
229 | |||
230 | export { | ||
231 | // Re export ffprobe utils | ||
232 | getVideoStreamDimensionsInfo, | ||
233 | buildFileMetadata, | ||
234 | getMaxAudioBitrate, | ||
235 | getVideoStream, | ||
236 | getVideoStreamDuration, | ||
237 | getAudioStream, | ||
238 | hasAudioStream, | ||
239 | getVideoStreamFPS, | ||
240 | ffprobePromise, | ||
241 | getVideoStreamBitrate, | ||
242 | |||
243 | getVideoStreamCodec, | ||
244 | getAudioStreamCodec, | ||
245 | |||
246 | computeFPS, | ||
247 | getClosestFramerateStandard, | ||
248 | |||
249 | computeResolutionsToTranscode, | ||
250 | |||
251 | canDoQuickTranscode, | ||
252 | canDoQuickVideoTranscode, | ||
253 | canDoQuickAudioTranscode | ||
254 | } | ||
diff --git a/server/helpers/ffmpeg/framerate.ts b/server/helpers/ffmpeg/framerate.ts new file mode 100644 index 000000000..18cb0e0e2 --- /dev/null +++ b/server/helpers/ffmpeg/framerate.ts | |||
@@ -0,0 +1,44 @@ | |||
1 | import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' | ||
2 | import { VideoResolution } from '@shared/models' | ||
3 | |||
4 | export function computeOutputFPS (options: { | ||
5 | inputFPS: number | ||
6 | resolution: VideoResolution | ||
7 | }) { | ||
8 | const { resolution } = options | ||
9 | |||
10 | let fps = options.inputFPS | ||
11 | |||
12 | if ( | ||
13 | // On small/medium resolutions, limit FPS | ||
14 | resolution !== undefined && | ||
15 | resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | ||
16 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | ||
17 | ) { | ||
18 | // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value | ||
19 | fps = getClosestFramerateStandard({ fps, type: 'STANDARD' }) | ||
20 | } | ||
21 | |||
22 | // Hard FPS limits | ||
23 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard({ fps, type: 'HD_STANDARD' }) | ||
24 | |||
25 | if (fps < VIDEO_TRANSCODING_FPS.MIN) { | ||
26 | throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${VIDEO_TRANSCODING_FPS.MIN}`) | ||
27 | } | ||
28 | |||
29 | return fps | ||
30 | } | ||
31 | |||
32 | // --------------------------------------------------------------------------- | ||
33 | // Private | ||
34 | // --------------------------------------------------------------------------- | ||
35 | |||
36 | function getClosestFramerateStandard (options: { | ||
37 | fps: number | ||
38 | type: 'HD_STANDARD' | 'STANDARD' | ||
39 | }) { | ||
40 | const { fps, type } = options | ||
41 | |||
42 | return VIDEO_TRANSCODING_FPS[type].slice(0) | ||
43 | .sort((a, b) => fps % a - fps % b)[0] | ||
44 | } | ||
diff --git a/server/helpers/ffmpeg/index.ts b/server/helpers/ffmpeg/index.ts index e3bb2013f..bf1c73fb6 100644 --- a/server/helpers/ffmpeg/index.ts +++ b/server/helpers/ffmpeg/index.ts | |||
@@ -1,8 +1,4 @@ | |||
1 | export * from './ffmpeg-commons' | 1 | export * from './codecs' |
2 | export * from './ffmpeg-edition' | 2 | export * from './ffmpeg-image' |
3 | export * from './ffmpeg-encoders' | 3 | export * from './ffmpeg-options' |
4 | export * from './ffmpeg-images' | 4 | export * from './framerate' |
5 | export * from './ffmpeg-live' | ||
6 | export * from './ffmpeg-presets' | ||
7 | export * from './ffmpeg-vod' | ||
8 | export * from './ffprobe-utils' | ||