aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/helpers/ffmpeg
diff options
context:
space:
mode:
Diffstat (limited to 'server/helpers/ffmpeg')
-rw-r--r--server/helpers/ffmpeg/codecs.ts64
-rw-r--r--server/helpers/ffmpeg/ffmpeg-commons.ts114
-rw-r--r--server/helpers/ffmpeg/ffmpeg-edition.ts258
-rw-r--r--server/helpers/ffmpeg/ffmpeg-encoders.ts116
-rw-r--r--server/helpers/ffmpeg/ffmpeg-image.ts14
-rw-r--r--server/helpers/ffmpeg/ffmpeg-images.ts46
-rw-r--r--server/helpers/ffmpeg/ffmpeg-live.ts204
-rw-r--r--server/helpers/ffmpeg/ffmpeg-options.ts45
-rw-r--r--server/helpers/ffmpeg/ffmpeg-presets.ts156
-rw-r--r--server/helpers/ffmpeg/ffmpeg-vod.ts267
-rw-r--r--server/helpers/ffmpeg/ffprobe-utils.ts254
-rw-r--r--server/helpers/ffmpeg/framerate.ts44
-rw-r--r--server/helpers/ffmpeg/index.ts12
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 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { getAudioStream, getVideoStream } from '@shared/ffmpeg'
3import { logger } from '../logger'
4import { forceNumber } from '@shared/core-utils'
5
6export 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
49export 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 @@
1import { Job } from 'bullmq'
2import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
3import { execPromise } from '@server/helpers/core-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config'
6import { FFMPEG_NICE } from '@server/initializers/constants'
7import { EncoderOptions } from '@shared/models'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11type StreamType = 'audio' | 'video'
12
13function 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
32function 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
56async 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
93function buildStreamSuffix (base: string, streamNum?: number) {
94 if (streamNum !== undefined) {
95 return `${base}:${streamNum}`
96 }
97
98 return base
99}
100
101function getScaleFilter (options: EncoderOptions): string {
102 if (options.scaleFilter) return options.scaleFilter.name
103
104 return 'scale'
105}
106
107export {
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 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { VIDEO_FILTERS } from '@server/initializers/constants'
3import { AvailableEncoders } from '@shared/models'
4import { logger, loggerTagsFactory } from '../logger'
5import { getFFmpeg, runCommand } from './ffmpeg-commons'
6import { presetVOD } from './ffmpeg-presets'
7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11async 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
53async 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
111async 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
254export {
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 @@
1import { getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4import { promisify0 } from '../core-utils'
5import { logger, loggerTagsFactory } from '../logger'
6
7const lTags = loggerTagsFactory('ffmpeg')
8
9// Detect supported encoders by ffmpeg
10let supportedEncoders: Map<string, boolean>
11async 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
39function 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
46async 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
111export {
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 @@
1import { FFmpegImage } from '@shared/ffmpeg'
2import { getFFmpegCommandWrapperOptions } from './ffmpeg-options'
3
4export function processGIF (options: Parameters<FFmpegImage['processGIF']>[0]) {
5 return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).processGIF(options)
6}
7
8export function generateThumbnailFromVideo (options: Parameters<FFmpegImage['generateThumbnailFromVideo']>[0]) {
9 return new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail')).generateThumbnailFromVideo(options)
10}
11
12export 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 @@
1import ffmpeg from 'fluent-ffmpeg'
2import { FFMPEG_NICE } from '@server/initializers/constants'
3import { runCommand } from './ffmpeg-commons'
4
5function 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
12function 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
25async 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
42export {
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 @@
1import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { VIDEO_LIVE } from '@server/initializers/constants'
4import { AvailableEncoders, LiveVideoLatencyMode } from '@shared/models'
5import { logger, loggerTagsFactory } from '../logger'
6import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
7import { getEncoderBuilderResult } from './ffmpeg-encoders'
8import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
9import { computeFPS } from './ffprobe-utils'
10
11const lTags = loggerTagsFactory('ffmpeg')
12
13async 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
149function 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
169function 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
179export {
180 getLiveSegmentTime,
181
182 getLiveTranscodingCommand,
183 getLiveMuxingCommand
184}
185
186// ---------------------------------------------------------------------------
187
188function 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 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { FFMPEG_NICE } from '@server/initializers/constants'
4import { FFmpegCommandWrapperOptions } from '@shared/ffmpeg'
5import { AvailableEncoders } from '@shared/models'
6
7type CommandType = 'live' | 'vod' | 'thumbnail'
8
9export 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
32function 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
40function 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 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { pick } from '@shared/core-utils'
4import { AvailableEncoders, EncoderOptions } from '@shared/models'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
6import { getEncoderBuilderResult } from './ffmpeg-encoders'
7import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11// ---------------------------------------------------------------------------
12
13function 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
22function 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
46async 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
125function presetCopy (command: FfmpegCommand): FfmpegCommand {
126 return command
127 .format('mp4')
128 .videoCodec('copy')
129 .audioCodec('copy')
130}
131
132function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
133 return command
134 .format('mp4')
135 .audioCodec('copy')
136 .noVideo()
137}
138
139function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
140 return command
141 .inputOptions(options.inputOptions ?? [])
142 .outputOptions(options.outputOptions ?? [])
143}
144
145// ---------------------------------------------------------------------------
146
147export {
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 @@
1import { MutexInterface } from 'async-mutex'
2import { Job } from 'bullmq'
3import { FfmpegCommand } from 'fluent-ffmpeg'
4import { readFile, writeFile } from 'fs-extra'
5import { dirname } from 'path'
6import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
7import { pick } from '@shared/core-utils'
8import { AvailableEncoders, VideoResolution } from '@shared/models'
9import { logger, loggerTagsFactory } from '../logger'
10import { getFFmpeg, runCommand } from './ffmpeg-commons'
11import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
12import { computeFPS, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS } from './ffprobe-utils'
13
14const lTags = loggerTagsFactory('ffmpeg')
15
16// ---------------------------------------------------------------------------
17
18type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
19
20interface 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
38interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
39 type: 'hls'
40 copyCodecs: boolean
41 hlsPlaylist: {
42 videoFilename: string
43 }
44}
45
46interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
47 type: 'hls-from-ts'
48
49 isAAC: boolean
50
51 hlsPlaylist: {
52 videoFilename: string
53 }
54}
55
56interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
57 type: 'quick-transcode'
58}
59
60interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
61 type: 'video'
62}
63
64interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
65 type: 'merge-audio'
66 audioPath: string
67}
68
69interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
70 type: 'only-audio'
71}
72
73type TranscodeVODOptions =
74 HLSTranscodeOptions
75 | HLSFromTSTranscodeOptions
76 | VideoTranscodeOptions
77 | MergeAudioTranscodeOptions
78 | OnlyAudioTranscodeOptions
79 | QuickTranscodeOptions
80
81// ---------------------------------------------------------------------------
82
83const 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
94async 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
115export {
116 transcodeVOD,
117
118 buildVODCommand,
119
120 TranscodeVODOptions,
121 TranscodeVODOptionsType
122}
123
124// ---------------------------------------------------------------------------
125
126async 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
156function 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
169async 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
193function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
194 command = presetOnlyAudio(command)
195
196 return command
197}
198
199// ---------------------------------------------------------------------------
200// HLS transcoding
201// ---------------------------------------------------------------------------
202
203async 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
215function 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
231function 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
241async 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
260function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
261 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
262}
263
264// Avoid "height not divisible by 2" error
265function 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 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { getMaxBitrate } from '@shared/core-utils'
3import {
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'
15import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
16import { CONFIG } from '../../initializers/config'
17import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
18import { toEven } from '../core-utils'
19import { 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
31async 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
74async 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
95function 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
148async 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
157async 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
177async 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
200function 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
205function 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
230export {
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 @@
1import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
2import { VideoResolution } from '@shared/models'
3
4export 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
36function 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 @@
1export * from './ffmpeg-commons' 1export * from './codecs'
2export * from './ffmpeg-edition' 2export * from './ffmpeg-image'
3export * from './ffmpeg-encoders' 3export * from './ffmpeg-options'
4export * from './ffmpeg-images' 4export * from './framerate'
5export * from './ffmpeg-live'
6export * from './ffmpeg-presets'
7export * from './ffmpeg-vod'
8export * from './ffprobe-utils'