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