aboutsummaryrefslogtreecommitdiffhomepage
path: root/shared/ffmpeg
diff options
context:
space:
mode:
Diffstat (limited to 'shared/ffmpeg')
-rw-r--r--shared/ffmpeg/ffmpeg-command-wrapper.ts234
-rw-r--r--shared/ffmpeg/ffmpeg-edition.ts239
-rw-r--r--shared/ffmpeg/ffmpeg-images.ts59
-rw-r--r--shared/ffmpeg/ffmpeg-live.ts184
-rw-r--r--shared/ffmpeg/ffmpeg-utils.ts17
-rw-r--r--shared/ffmpeg/ffmpeg-version.ts24
-rw-r--r--shared/ffmpeg/ffmpeg-vod.ts256
-rw-r--r--shared/ffmpeg/ffprobe.ts184
-rw-r--r--shared/ffmpeg/index.ts8
-rw-r--r--shared/ffmpeg/shared/encoder-options.ts39
-rw-r--r--shared/ffmpeg/shared/index.ts2
-rw-r--r--shared/ffmpeg/shared/presets.ts93
12 files changed, 1339 insertions, 0 deletions
diff --git a/shared/ffmpeg/ffmpeg-command-wrapper.ts b/shared/ffmpeg/ffmpeg-command-wrapper.ts
new file mode 100644
index 000000000..7a8c19d4b
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-command-wrapper.ts
@@ -0,0 +1,234 @@
1import ffmpeg, { FfmpegCommand, getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick, promisify0 } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4
5type FFmpegLogger = {
6 info: (msg: string, obj?: any) => void
7 debug: (msg: string, obj?: any) => void
8 warn: (msg: string, obj?: any) => void
9 error: (msg: string, obj?: any) => void
10}
11
12export interface FFmpegCommandWrapperOptions {
13 availableEncoders?: AvailableEncoders
14 profile?: string
15
16 niceness: number
17 tmpDirectory: string
18 threads: number
19
20 logger: FFmpegLogger
21 lTags?: { tags: string[] }
22
23 updateJobProgress?: (progress?: number) => void
24}
25
26export class FFmpegCommandWrapper {
27 private static supportedEncoders: Map<string, boolean>
28
29 private readonly availableEncoders: AvailableEncoders
30 private readonly profile: string
31
32 private readonly niceness: number
33 private readonly tmpDirectory: string
34 private readonly threads: number
35
36 private readonly logger: FFmpegLogger
37 private readonly lTags: { tags: string[] }
38
39 private readonly updateJobProgress: (progress?: number) => void
40
41 private command: FfmpegCommand
42
43 constructor (options: FFmpegCommandWrapperOptions) {
44 this.availableEncoders = options.availableEncoders
45 this.profile = options.profile
46 this.niceness = options.niceness
47 this.tmpDirectory = options.tmpDirectory
48 this.threads = options.threads
49 this.logger = options.logger
50 this.lTags = options.lTags || { tags: [] }
51 this.updateJobProgress = options.updateJobProgress
52 }
53
54 getAvailableEncoders () {
55 return this.availableEncoders
56 }
57
58 getProfile () {
59 return this.profile
60 }
61
62 getCommand () {
63 return this.command
64 }
65
66 // ---------------------------------------------------------------------------
67
68 debugLog (msg: string, meta: any) {
69 this.logger.debug(msg, { ...meta, ...this.lTags })
70 }
71
72 // ---------------------------------------------------------------------------
73
74 buildCommand (input: string) {
75 if (this.command) throw new Error('Command is already built')
76
77 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
78 this.command = ffmpeg(input, {
79 niceness: this.niceness,
80 cwd: this.tmpDirectory
81 })
82
83 if (this.threads > 0) {
84 // If we don't set any threads ffmpeg will chose automatically
85 this.command.outputOption('-threads ' + this.threads)
86 }
87
88 return this.command
89 }
90
91 async runCommand (options: {
92 silent?: boolean // false by default
93 } = {}) {
94 const { silent = false } = options
95
96 return new Promise<void>((res, rej) => {
97 let shellCommand: string
98
99 this.command.on('start', cmdline => { shellCommand = cmdline })
100
101 this.command.on('error', (err, stdout, stderr) => {
102 if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
103
104 rej(err)
105 })
106
107 this.command.on('end', (stdout, stderr) => {
108 this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
109
110 res()
111 })
112
113 if (this.updateJobProgress) {
114 this.command.on('progress', progress => {
115 if (!progress.percent) return
116
117 // Sometimes ffmpeg returns an invalid progress
118 let percent = Math.round(progress.percent)
119 if (percent < 0) percent = 0
120 if (percent > 100) percent = 100
121
122 this.updateJobProgress(percent)
123 })
124 }
125
126 this.command.run()
127 })
128 }
129
130 // ---------------------------------------------------------------------------
131
132 static resetSupportedEncoders () {
133 FFmpegCommandWrapper.supportedEncoders = undefined
134 }
135
136 // Run encoder builder depending on available encoders
137 // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
138 // If the default one does not exist, check the next encoder
139 async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
140 streamType: 'video' | 'audio'
141 input: string
142
143 videoType: 'vod' | 'live'
144 }) {
145 if (!this.availableEncoders) {
146 throw new Error('There is no available encoders')
147 }
148
149 const { streamType, videoType } = options
150
151 const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
152 const encoders = this.availableEncoders.available[videoType]
153
154 for (const encoder of encodersToTry) {
155 if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
156 this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
157 continue
158 }
159
160 if (!encoders[encoder]) {
161 this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
162 continue
163 }
164
165 // An object containing available profiles for this encoder
166 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
167 let builder = builderProfiles[this.profile]
168
169 if (!builder) {
170 this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
171 builder = builderProfiles.default
172
173 if (!builder) {
174 this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
175 continue
176 }
177 }
178
179 const result = await builder(
180 pick(options, [
181 'input',
182 'canCopyAudio',
183 'canCopyVideo',
184 'resolution',
185 'inputBitrate',
186 'fps',
187 'inputRatio',
188 'streamNum'
189 ])
190 )
191
192 return {
193 result,
194
195 // If we don't have output options, then copy the input stream
196 encoder: result.copy === true
197 ? 'copy'
198 : encoder
199 }
200 }
201
202 return null
203 }
204
205 // Detect supported encoders by ffmpeg
206 private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
207 if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
208 return FFmpegCommandWrapper.supportedEncoders
209 }
210
211 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
212 const availableFFmpegEncoders = await getAvailableEncodersPromise()
213
214 const searchEncoders = new Set<string>()
215 for (const type of [ 'live', 'vod' ]) {
216 for (const streamType of [ 'audio', 'video' ]) {
217 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
218 searchEncoders.add(encoder)
219 }
220 }
221 }
222
223 const supportedEncoders = new Map<string, boolean>()
224
225 for (const searchEncoder of searchEncoders) {
226 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
227 }
228
229 this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
230
231 FFmpegCommandWrapper.supportedEncoders = supportedEncoders
232 return supportedEncoders
233 }
234}
diff --git a/shared/ffmpeg/ffmpeg-edition.ts b/shared/ffmpeg/ffmpeg-edition.ts
new file mode 100644
index 000000000..724ca1ea9
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-edition.ts
@@ -0,0 +1,239 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
3import { presetVOD } from './shared/presets'
4import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe'
5
6export class FFmpegEdition {
7 private readonly commandWrapper: FFmpegCommandWrapper
8
9 constructor (options: FFmpegCommandWrapperOptions) {
10 this.commandWrapper = new FFmpegCommandWrapper(options)
11 }
12
13 async cutVideo (options: {
14 inputPath: string
15 outputPath: string
16 start?: number
17 end?: number
18 }) {
19 const { inputPath, outputPath } = options
20
21 const mainProbe = await ffprobePromise(inputPath)
22 const fps = await getVideoStreamFPS(inputPath, mainProbe)
23 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
24
25 const command = this.commandWrapper.buildCommand(inputPath)
26 .output(outputPath)
27
28 await presetVOD({
29 commandWrapper: this.commandWrapper,
30 input: inputPath,
31 resolution,
32 fps,
33 canCopyAudio: false,
34 canCopyVideo: false
35 })
36
37 if (options.start) {
38 command.outputOption('-ss ' + options.start)
39 }
40
41 if (options.end) {
42 command.outputOption('-to ' + options.end)
43 }
44
45 await this.commandWrapper.runCommand()
46 }
47
48 async addWatermark (options: {
49 inputPath: string
50 watermarkPath: string
51 outputPath: string
52
53 videoFilters: {
54 watermarkSizeRatio: number
55 horitonzalMarginRatio: number
56 verticalMarginRatio: number
57 }
58 }) {
59 const { watermarkPath, inputPath, outputPath, videoFilters } = options
60
61 const videoProbe = await ffprobePromise(inputPath)
62 const fps = await getVideoStreamFPS(inputPath, videoProbe)
63 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
64
65 const command = this.commandWrapper.buildCommand(inputPath)
66 .output(outputPath)
67
68 command.input(watermarkPath)
69
70 await presetVOD({
71 commandWrapper: this.commandWrapper,
72 input: inputPath,
73 resolution,
74 fps,
75 canCopyAudio: true,
76 canCopyVideo: false
77 })
78
79 const complexFilter: FilterSpecification[] = [
80 // Scale watermark
81 {
82 inputs: [ '[1]', '[0]' ],
83 filter: 'scale2ref',
84 options: {
85 w: 'oh*mdar',
86 h: `ih*${videoFilters.watermarkSizeRatio}`
87 },
88 outputs: [ '[watermark]', '[video]' ]
89 },
90
91 {
92 inputs: [ '[video]', '[watermark]' ],
93 filter: 'overlay',
94 options: {
95 x: `main_w - overlay_w - (main_h * ${videoFilters.horitonzalMarginRatio})`,
96 y: `main_h * ${videoFilters.verticalMarginRatio}`
97 }
98 }
99 ]
100
101 command.complexFilter(complexFilter)
102
103 await this.commandWrapper.runCommand()
104 }
105
106 async addIntroOutro (options: {
107 inputPath: string
108 introOutroPath: string
109 outputPath: string
110 type: 'intro' | 'outro'
111 }) {
112 const { introOutroPath, inputPath, outputPath, type } = options
113
114 const mainProbe = await ffprobePromise(inputPath)
115 const fps = await getVideoStreamFPS(inputPath, mainProbe)
116 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
117 const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
118
119 const introOutroProbe = await ffprobePromise(introOutroPath)
120 const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
121
122 const command = this.commandWrapper.buildCommand(inputPath)
123 .output(outputPath)
124
125 command.input(introOutroPath)
126
127 if (!introOutroHasAudio && mainHasAudio) {
128 const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
129
130 command.input('anullsrc')
131 command.withInputFormat('lavfi')
132 command.withInputOption('-t ' + duration)
133 }
134
135 await presetVOD({
136 commandWrapper: this.commandWrapper,
137 input: inputPath,
138 resolution,
139 fps,
140 canCopyAudio: false,
141 canCopyVideo: false
142 })
143
144 // Add black background to correctly scale intro/outro with padding
145 const complexFilter: FilterSpecification[] = [
146 {
147 inputs: [ '1', '0' ],
148 filter: 'scale2ref',
149 options: {
150 w: 'iw',
151 h: `ih`
152 },
153 outputs: [ 'intro-outro', 'main' ]
154 },
155 {
156 inputs: [ 'intro-outro', 'main' ],
157 filter: 'scale2ref',
158 options: {
159 w: 'iw',
160 h: `ih`
161 },
162 outputs: [ 'to-scale', 'main' ]
163 },
164 {
165 inputs: 'to-scale',
166 filter: 'drawbox',
167 options: {
168 t: 'fill'
169 },
170 outputs: [ 'to-scale-bg' ]
171 },
172 {
173 inputs: [ '1', 'to-scale-bg' ],
174 filter: 'scale2ref',
175 options: {
176 w: 'iw',
177 h: 'ih',
178 force_original_aspect_ratio: 'decrease',
179 flags: 'spline'
180 },
181 outputs: [ 'to-scale', 'to-scale-bg' ]
182 },
183 {
184 inputs: [ 'to-scale-bg', 'to-scale' ],
185 filter: 'overlay',
186 options: {
187 x: '(main_w - overlay_w)/2',
188 y: '(main_h - overlay_h)/2'
189 },
190 outputs: 'intro-outro-resized'
191 }
192 ]
193
194 const concatFilter = {
195 inputs: [],
196 filter: 'concat',
197 options: {
198 n: 2,
199 v: 1,
200 unsafe: 1
201 },
202 outputs: [ 'v' ]
203 }
204
205 const introOutroFilterInputs = [ 'intro-outro-resized' ]
206 const mainFilterInputs = [ 'main' ]
207
208 if (mainHasAudio) {
209 mainFilterInputs.push('0:a')
210
211 if (introOutroHasAudio) {
212 introOutroFilterInputs.push('1:a')
213 } else {
214 // Silent input
215 introOutroFilterInputs.push('2:a')
216 }
217 }
218
219 if (type === 'intro') {
220 concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
221 } else {
222 concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
223 }
224
225 if (mainHasAudio) {
226 concatFilter.options['a'] = 1
227 concatFilter.outputs.push('a')
228
229 command.outputOption('-map [a]')
230 }
231
232 command.outputOption('-map [v]')
233
234 complexFilter.push(concatFilter)
235 command.complexFilter(complexFilter)
236
237 await this.commandWrapper.runCommand()
238 }
239}
diff --git a/shared/ffmpeg/ffmpeg-images.ts b/shared/ffmpeg/ffmpeg-images.ts
new file mode 100644
index 000000000..2db63bd8b
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-images.ts
@@ -0,0 +1,59 @@
1import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
2
3export class FFmpegImage {
4 private readonly commandWrapper: FFmpegCommandWrapper
5
6 constructor (options: FFmpegCommandWrapperOptions) {
7 this.commandWrapper = new FFmpegCommandWrapper(options)
8 }
9
10 convertWebPToJPG (options: {
11 path: string
12 destination: string
13 }): Promise<void> {
14 const { path, destination } = options
15
16 this.commandWrapper.buildCommand(path)
17 .output(destination)
18
19 return this.commandWrapper.runCommand({ silent: true })
20 }
21
22 processGIF (options: {
23 path: string
24 destination: string
25 newSize: { width: number, height: number }
26 }): Promise<void> {
27 const { path, destination, newSize } = options
28
29 this.commandWrapper.buildCommand(path)
30 .fps(20)
31 .size(`${newSize.width}x${newSize.height}`)
32 .output(destination)
33
34 return this.commandWrapper.runCommand()
35 }
36
37 async generateThumbnailFromVideo (options: {
38 fromPath: string
39 folder: string
40 imageName: string
41 }) {
42 const { fromPath, folder, imageName } = options
43
44 const pendingImageName = 'pending-' + imageName
45
46 const thumbnailOptions = {
47 filename: pendingImageName,
48 count: 1,
49 folder
50 }
51
52 return new Promise<string>((res, rej) => {
53 this.commandWrapper.buildCommand(fromPath)
54 .on('error', rej)
55 .on('end', () => res(imageName))
56 .thumbnail(thumbnailOptions)
57 })
58 }
59}
diff --git a/shared/ffmpeg/ffmpeg-live.ts b/shared/ffmpeg/ffmpeg-live.ts
new file mode 100644
index 000000000..cca4c6474
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-live.ts
@@ -0,0 +1,184 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { pick } from '@shared/core-utils'
4import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils'
6import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared'
7
8export class FFmpegLive {
9 private readonly commandWrapper: FFmpegCommandWrapper
10
11 constructor (options: FFmpegCommandWrapperOptions) {
12 this.commandWrapper = new FFmpegCommandWrapper(options)
13 }
14
15 async getLiveTranscodingCommand (options: {
16 inputUrl: string
17
18 outPath: string
19 masterPlaylistName: string
20
21 toTranscode: {
22 resolution: number
23 fps: number
24 }[]
25
26 // Input information
27 bitrate: number
28 ratio: number
29 hasAudio: boolean
30
31 segmentListSize: number
32 segmentDuration: number
33 }) {
34 const {
35 inputUrl,
36 outPath,
37 toTranscode,
38 bitrate,
39 masterPlaylistName,
40 ratio,
41 hasAudio
42 } = options
43 const command = this.commandWrapper.buildCommand(inputUrl)
44
45 const varStreamMap: string[] = []
46
47 const complexFilter: FilterSpecification[] = [
48 {
49 inputs: '[v:0]',
50 filter: 'split',
51 options: toTranscode.length,
52 outputs: toTranscode.map(t => `vtemp${t.resolution}`)
53 }
54 ]
55
56 command.outputOption('-sc_threshold 0')
57
58 addDefaultEncoderGlobalParams(command)
59
60 for (let i = 0; i < toTranscode.length; i++) {
61 const streamMap: string[] = []
62 const { resolution, fps } = toTranscode[i]
63
64 const baseEncoderBuilderParams = {
65 input: inputUrl,
66
67 canCopyAudio: true,
68 canCopyVideo: true,
69
70 inputBitrate: bitrate,
71 inputRatio: ratio,
72
73 resolution,
74 fps,
75
76 streamNum: i,
77 videoType: 'live' as 'live'
78 }
79
80 {
81 const streamType: StreamType = 'video'
82 const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
83 if (!builderResult) {
84 throw new Error('No available live video encoder found')
85 }
86
87 command.outputOption(`-map [vout${resolution}]`)
88
89 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
90
91 this.commandWrapper.debugLog(
92 `Apply ffmpeg live video params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
93 { builderResult, fps, toTranscode }
94 )
95
96 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
97 applyEncoderOptions(command, builderResult.result)
98
99 complexFilter.push({
100 inputs: `vtemp${resolution}`,
101 filter: getScaleFilter(builderResult.result),
102 options: `w=-2:h=${resolution}`,
103 outputs: `vout${resolution}`
104 })
105
106 streamMap.push(`v:${i}`)
107 }
108
109 if (hasAudio) {
110 const streamType: StreamType = 'audio'
111 const builderResult = await this.commandWrapper.getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
112 if (!builderResult) {
113 throw new Error('No available live audio encoder found')
114 }
115
116 command.outputOption('-map a:0')
117
118 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps, streamNum: i })
119
120 this.commandWrapper.debugLog(
121 `Apply ffmpeg live audio params from ${builderResult.encoder} using ${this.commandWrapper.getProfile()} profile.`,
122 { builderResult, fps, resolution }
123 )
124
125 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
126 applyEncoderOptions(command, builderResult.result)
127
128 streamMap.push(`a:${i}`)
129 }
130
131 varStreamMap.push(streamMap.join(','))
132 }
133
134 command.complexFilter(complexFilter)
135
136 this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
137
138 command.outputOption('-var_stream_map', varStreamMap.join(' '))
139
140 return command
141 }
142
143 getLiveMuxingCommand (options: {
144 inputUrl: string
145 outPath: string
146 masterPlaylistName: string
147
148 segmentListSize: number
149 segmentDuration: number
150 }) {
151 const { inputUrl, outPath, masterPlaylistName } = options
152
153 const command = this.commandWrapper.buildCommand(inputUrl)
154
155 command.outputOption('-c:v copy')
156 command.outputOption('-c:a copy')
157 command.outputOption('-map 0:a?')
158 command.outputOption('-map 0:v?')
159
160 this.addDefaultLiveHLSParams({ ...pick(options, [ 'segmentDuration', 'segmentListSize' ]), outPath, masterPlaylistName })
161
162 return command
163 }
164
165 private addDefaultLiveHLSParams (options: {
166 outPath: string
167 masterPlaylistName: string
168 segmentListSize: number
169 segmentDuration: number
170 }) {
171 const { outPath, masterPlaylistName, segmentListSize, segmentDuration } = options
172
173 const command = this.commandWrapper.getCommand()
174
175 command.outputOption('-hls_time ' + segmentDuration)
176 command.outputOption('-hls_list_size ' + segmentListSize)
177 command.outputOption('-hls_flags delete_segments+independent_segments+program_date_time')
178 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
179 command.outputOption('-master_pl_name ' + masterPlaylistName)
180 command.outputOption(`-f hls`)
181
182 command.output(join(outPath, '%v.m3u8'))
183 }
184}
diff --git a/shared/ffmpeg/ffmpeg-utils.ts b/shared/ffmpeg/ffmpeg-utils.ts
new file mode 100644
index 000000000..7d09c32ca
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-utils.ts
@@ -0,0 +1,17 @@
1import { EncoderOptions } from '@shared/models'
2
3export type StreamType = 'audio' | 'video'
4
5export function buildStreamSuffix (base: string, streamNum?: number) {
6 if (streamNum !== undefined) {
7 return `${base}:${streamNum}`
8 }
9
10 return base
11}
12
13export function getScaleFilter (options: EncoderOptions): string {
14 if (options.scaleFilter) return options.scaleFilter.name
15
16 return 'scale'
17}
diff --git a/shared/ffmpeg/ffmpeg-version.ts b/shared/ffmpeg/ffmpeg-version.ts
new file mode 100644
index 000000000..41d9b2d89
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-version.ts
@@ -0,0 +1,24 @@
1import { exec } from 'child_process'
2import ffmpeg from 'fluent-ffmpeg'
3
4export function getFFmpegVersion () {
5 return new Promise<string>((res, rej) => {
6 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
7 if (err) return rej(err)
8 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
9
10 return exec(`${ffmpegPath} -version`, (err, stdout) => {
11 if (err) return rej(err)
12
13 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
14 if (!parsed?.[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
15
16 // Fix ffmpeg version that does not include patch version (4.4 for example)
17 let version = parsed[1]
18 if (version.match(/^\d+\.\d+$/)) {
19 version += '.0'
20 }
21 })
22 })
23 })
24}
diff --git a/shared/ffmpeg/ffmpeg-vod.ts b/shared/ffmpeg/ffmpeg-vod.ts
new file mode 100644
index 000000000..e40ca0a1e
--- /dev/null
+++ b/shared/ffmpeg/ffmpeg-vod.ts
@@ -0,0 +1,256 @@
1import { MutexInterface } from 'async-mutex'
2import { FfmpegCommand } from 'fluent-ffmpeg'
3import { readFile, writeFile } from 'fs-extra'
4import { dirname } from 'path'
5import { pick } from '@shared/core-utils'
6import { VideoResolution } from '@shared/models'
7import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
8import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe'
9import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets'
10
11export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
12
13export interface BaseTranscodeVODOptions {
14 type: TranscodeVODOptionsType
15
16 inputPath: string
17 outputPath: string
18
19 // Will be released after the ffmpeg started
20 // To prevent a bug where the input file does not exist anymore when running ffmpeg
21 inputFileMutexReleaser: MutexInterface.Releaser
22
23 resolution: number
24 fps: number
25}
26
27export interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
28 type: 'hls'
29
30 copyCodecs: boolean
31
32 hlsPlaylist: {
33 videoFilename: string
34 }
35}
36
37export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
38 type: 'hls-from-ts'
39
40 isAAC: boolean
41
42 hlsPlaylist: {
43 videoFilename: string
44 }
45}
46
47export interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
48 type: 'quick-transcode'
49}
50
51export interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
52 type: 'video'
53}
54
55export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
56 type: 'merge-audio'
57 audioPath: string
58}
59
60export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
61 type: 'only-audio'
62}
63
64export type TranscodeVODOptions =
65 HLSTranscodeOptions
66 | HLSFromTSTranscodeOptions
67 | VideoTranscodeOptions
68 | MergeAudioTranscodeOptions
69 | OnlyAudioTranscodeOptions
70 | QuickTranscodeOptions
71
72// ---------------------------------------------------------------------------
73
74export class FFmpegVOD {
75 private readonly commandWrapper: FFmpegCommandWrapper
76
77 private ended = false
78
79 constructor (options: FFmpegCommandWrapperOptions) {
80 this.commandWrapper = new FFmpegCommandWrapper(options)
81 }
82
83 async transcode (options: TranscodeVODOptions) {
84 const builders: {
85 [ type in TranscodeVODOptionsType ]: (options: TranscodeVODOptions) => Promise<void> | void
86 } = {
87 'quick-transcode': this.buildQuickTranscodeCommand.bind(this),
88 'hls': this.buildHLSVODCommand.bind(this),
89 'hls-from-ts': this.buildHLSVODFromTSCommand.bind(this),
90 'merge-audio': this.buildAudioMergeCommand.bind(this),
91 // TODO: remove, we merge this in buildWebVideoCommand
92 'only-audio': this.buildOnlyAudioCommand.bind(this),
93 'video': this.buildWebVideoCommand.bind(this)
94 }
95
96 this.commandWrapper.debugLog('Will run transcode.', { options })
97
98 const command = this.commandWrapper.buildCommand(options.inputPath)
99 .output(options.outputPath)
100
101 await builders[options.type](options)
102
103 command.on('start', () => {
104 setTimeout(() => {
105 options.inputFileMutexReleaser()
106 }, 1000)
107 })
108
109 await this.commandWrapper.runCommand()
110
111 await this.fixHLSPlaylistIfNeeded(options)
112
113 this.ended = true
114 }
115
116 isEnded () {
117 return this.ended
118 }
119
120 private async buildWebVideoCommand (options: TranscodeVODOptions) {
121 const { resolution, fps, inputPath } = options
122
123 if (resolution === VideoResolution.H_NOVIDEO) {
124 presetOnlyAudio(this.commandWrapper)
125 return
126 }
127
128 let scaleFilterValue: string
129
130 if (resolution !== undefined) {
131 const probe = await ffprobePromise(inputPath)
132 const videoStreamInfo = await getVideoStreamDimensionsInfo(inputPath, probe)
133
134 scaleFilterValue = videoStreamInfo?.isPortraitMode === true
135 ? `w=${resolution}:h=-2`
136 : `w=-2:h=${resolution}`
137 }
138
139 await presetVOD({
140 commandWrapper: this.commandWrapper,
141
142 resolution,
143 input: inputPath,
144 canCopyAudio: true,
145 canCopyVideo: true,
146 fps,
147 scaleFilterValue
148 })
149 }
150
151 private buildQuickTranscodeCommand (_options: TranscodeVODOptions) {
152 const command = this.commandWrapper.getCommand()
153
154 presetCopy(this.commandWrapper)
155
156 command.outputOption('-map_metadata -1') // strip all metadata
157 .outputOption('-movflags faststart')
158 }
159
160 // ---------------------------------------------------------------------------
161 // Audio transcoding
162 // ---------------------------------------------------------------------------
163
164 private async buildAudioMergeCommand (options: MergeAudioTranscodeOptions) {
165 const command = this.commandWrapper.getCommand()
166
167 command.loop(undefined)
168
169 await presetVOD({
170 ...pick(options, [ 'resolution' ]),
171
172 commandWrapper: this.commandWrapper,
173 input: options.audioPath,
174 canCopyAudio: true,
175 canCopyVideo: true,
176 fps: options.fps,
177 scaleFilterValue: this.getMergeAudioScaleFilterValue()
178 })
179
180 command.outputOption('-preset:v veryfast')
181
182 command.input(options.audioPath)
183 .outputOption('-tune stillimage')
184 .outputOption('-shortest')
185 }
186
187 private buildOnlyAudioCommand (_options: OnlyAudioTranscodeOptions) {
188 presetOnlyAudio(this.commandWrapper)
189 }
190
191 // Avoid "height not divisible by 2" error
192 private getMergeAudioScaleFilterValue () {
193 return 'trunc(iw/2)*2:trunc(ih/2)*2'
194 }
195
196 // ---------------------------------------------------------------------------
197 // HLS transcoding
198 // ---------------------------------------------------------------------------
199
200 private async buildHLSVODCommand (options: HLSTranscodeOptions) {
201 const command = this.commandWrapper.getCommand()
202
203 const videoPath = this.getHLSVideoPath(options)
204
205 if (options.copyCodecs) presetCopy(this.commandWrapper)
206 else if (options.resolution === VideoResolution.H_NOVIDEO) presetOnlyAudio(this.commandWrapper)
207 else await this.buildWebVideoCommand(options)
208
209 this.addCommonHLSVODCommandOptions(command, videoPath)
210 }
211
212 private buildHLSVODFromTSCommand (options: HLSFromTSTranscodeOptions) {
213 const command = this.commandWrapper.getCommand()
214
215 const videoPath = this.getHLSVideoPath(options)
216
217 command.outputOption('-c copy')
218
219 if (options.isAAC) {
220 // Required for example when copying an AAC stream from an MPEG-TS
221 // Since it's a bitstream filter, we don't need to reencode the audio
222 command.outputOption('-bsf:a aac_adtstoasc')
223 }
224
225 this.addCommonHLSVODCommandOptions(command, videoPath)
226 }
227
228 private addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
229 return command.outputOption('-hls_time 4')
230 .outputOption('-hls_list_size 0')
231 .outputOption('-hls_playlist_type vod')
232 .outputOption('-hls_segment_filename ' + outputPath)
233 .outputOption('-hls_segment_type fmp4')
234 .outputOption('-f hls')
235 .outputOption('-hls_flags single_file')
236 }
237
238 private async fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
239 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
240
241 const fileContent = await readFile(options.outputPath)
242
243 const videoFileName = options.hlsPlaylist.videoFilename
244 const videoFilePath = this.getHLSVideoPath(options)
245
246 // Fix wrong mapping with some ffmpeg versions
247 const newContent = fileContent.toString()
248 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
249
250 await writeFile(options.outputPath, newContent)
251 }
252
253 private getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
254 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
255 }
256}
diff --git a/shared/ffmpeg/ffprobe.ts b/shared/ffmpeg/ffprobe.ts
new file mode 100644
index 000000000..fda08c28e
--- /dev/null
+++ b/shared/ffmpeg/ffprobe.ts
@@ -0,0 +1,184 @@
1import { ffprobe, FfprobeData } from 'fluent-ffmpeg'
2import { forceNumber } from '@shared/core-utils'
3import { VideoResolution } from '@shared/models/videos'
4
5/**
6 *
7 * Helpers to run ffprobe and extract data from the JSON output
8 *
9 */
10
11function ffprobePromise (path: string) {
12 return new Promise<FfprobeData>((res, rej) => {
13 ffprobe(path, (err, data) => {
14 if (err) return rej(err)
15
16 return res(data)
17 })
18 })
19}
20
21// ---------------------------------------------------------------------------
22// Audio
23// ---------------------------------------------------------------------------
24
25const imageCodecs = new Set([
26 'ansi', 'apng', 'bintext', 'bmp', 'brender_pix', 'dpx', 'exr', 'fits', 'gem', 'gif', 'jpeg2000', 'jpgls', 'mjpeg', 'mjpegb', 'msp2',
27 'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', 'pgx', 'photocd', 'pictor', 'png', 'ppm', 'psd', 'sgi', 'sunrast', 'svg', 'targa', 'tiff',
28 'txd', 'webp', 'xbin', 'xbm', 'xface', 'xpm', 'xwd'
29])
30
31async function isAudioFile (path: string, existingProbe?: FfprobeData) {
32 const videoStream = await getVideoStream(path, existingProbe)
33 if (!videoStream) return true
34
35 if (imageCodecs.has(videoStream.codec_name)) return true
36
37 return false
38}
39
40async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
41 const { audioStream } = await getAudioStream(path, existingProbe)
42
43 return !!audioStream
44}
45
46async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
47 // without position, ffprobe considers the last input only
48 // we make it consider the first input only
49 // if you pass a file path to pos, then ffprobe acts on that file directly
50 const data = existingProbe || await ffprobePromise(videoPath)
51
52 if (Array.isArray(data.streams)) {
53 const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio')
54
55 if (audioStream) {
56 return {
57 absolutePath: data.format.filename,
58 audioStream,
59 bitrate: forceNumber(audioStream['bit_rate'])
60 }
61 }
62 }
63
64 return { absolutePath: data.format.filename }
65}
66
67function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) {
68 const maxKBitrate = 384
69 const kToBits = (kbits: number) => kbits * 1000
70
71 // If we did not manage to get the bitrate, use an average value
72 if (!bitrate) return 256
73
74 if (type === 'aac') {
75 switch (true) {
76 case bitrate > kToBits(maxKBitrate):
77 return maxKBitrate
78
79 default:
80 return -1 // we interpret it as a signal to copy the audio stream as is
81 }
82 }
83
84 /*
85 a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac.
86 That's why, when using aac, we can go to lower kbit/sec. The equivalences
87 made here are not made to be accurate, especially with good mp3 encoders.
88 */
89 switch (true) {
90 case bitrate <= kToBits(192):
91 return 128
92
93 case bitrate <= kToBits(384):
94 return 256
95
96 default:
97 return maxKBitrate
98 }
99}
100
101// ---------------------------------------------------------------------------
102// Video
103// ---------------------------------------------------------------------------
104
105async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
106 const videoStream = await getVideoStream(path, existingProbe)
107 if (!videoStream) {
108 return {
109 width: 0,
110 height: 0,
111 ratio: 0,
112 resolution: VideoResolution.H_NOVIDEO,
113 isPortraitMode: false
114 }
115 }
116
117 return {
118 width: videoStream.width,
119 height: videoStream.height,
120 ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width),
121 resolution: Math.min(videoStream.height, videoStream.width),
122 isPortraitMode: videoStream.height > videoStream.width
123 }
124}
125
126async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
127 const videoStream = await getVideoStream(path, existingProbe)
128 if (!videoStream) return 0
129
130 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
131 const valuesText: string = videoStream[key]
132 if (!valuesText) continue
133
134 const [ frames, seconds ] = valuesText.split('/')
135 if (!frames || !seconds) continue
136
137 const result = parseInt(frames, 10) / parseInt(seconds, 10)
138 if (result > 0) return Math.round(result)
139 }
140
141 return 0
142}
143
144async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
145 const metadata = existingProbe || await ffprobePromise(path)
146
147 let bitrate = metadata.format.bit_rate
148 if (bitrate && !isNaN(bitrate)) return bitrate
149
150 const videoStream = await getVideoStream(path, existingProbe)
151 if (!videoStream) return undefined
152
153 bitrate = forceNumber(videoStream?.bit_rate)
154 if (bitrate && !isNaN(bitrate)) return bitrate
155
156 return undefined
157}
158
159async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
160 const metadata = existingProbe || await ffprobePromise(path)
161
162 return Math.round(metadata.format.duration)
163}
164
165async function getVideoStream (path: string, existingProbe?: FfprobeData) {
166 const metadata = existingProbe || await ffprobePromise(path)
167
168 return metadata.streams.find(s => s.codec_type === 'video')
169}
170
171// ---------------------------------------------------------------------------
172
173export {
174 getVideoStreamDimensionsInfo,
175 getMaxAudioBitrate,
176 getVideoStream,
177 getVideoStreamDuration,
178 getAudioStream,
179 getVideoStreamFPS,
180 isAudioFile,
181 ffprobePromise,
182 getVideoStreamBitrate,
183 hasAudioStream
184}
diff --git a/shared/ffmpeg/index.ts b/shared/ffmpeg/index.ts
new file mode 100644
index 000000000..07a7d5402
--- /dev/null
+++ b/shared/ffmpeg/index.ts
@@ -0,0 +1,8 @@
1export * from './ffmpeg-command-wrapper'
2export * from './ffmpeg-edition'
3export * from './ffmpeg-images'
4export * from './ffmpeg-live'
5export * from './ffmpeg-utils'
6export * from './ffmpeg-version'
7export * from './ffmpeg-vod'
8export * from './ffprobe'
diff --git a/shared/ffmpeg/shared/encoder-options.ts b/shared/ffmpeg/shared/encoder-options.ts
new file mode 100644
index 000000000..9692a6b02
--- /dev/null
+++ b/shared/ffmpeg/shared/encoder-options.ts
@@ -0,0 +1,39 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { EncoderOptions } from '@shared/models'
3import { buildStreamSuffix } from '../ffmpeg-utils'
4
5export function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
6 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
7 command.outputOption('-max_muxing_queue_size 1024')
8 // strip all metadata
9 .outputOption('-map_metadata -1')
10 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
11 .outputOption('-pix_fmt yuv420p')
12}
13
14export function addDefaultEncoderParams (options: {
15 command: FfmpegCommand
16 encoder: 'libx264' | string
17 fps: number
18
19 streamNum?: number
20}) {
21 const { command, encoder, fps, streamNum } = options
22
23 if (encoder === 'libx264') {
24 // 3.1 is the minimal resource allocation for our highest supported resolution
25 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
26
27 if (fps) {
28 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
29 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
30 // https://superuser.com/a/908325
31 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
32 }
33 }
34}
35
36export function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions) {
37 command.inputOptions(options.inputOptions ?? [])
38 .outputOptions(options.outputOptions ?? [])
39}
diff --git a/shared/ffmpeg/shared/index.ts b/shared/ffmpeg/shared/index.ts
new file mode 100644
index 000000000..51de0316f
--- /dev/null
+++ b/shared/ffmpeg/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './encoder-options'
2export * from './presets'
diff --git a/shared/ffmpeg/shared/presets.ts b/shared/ffmpeg/shared/presets.ts
new file mode 100644
index 000000000..dcebdc1cf
--- /dev/null
+++ b/shared/ffmpeg/shared/presets.ts
@@ -0,0 +1,93 @@
1import { pick } from '@shared/core-utils'
2import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper'
3import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe'
4import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options'
5import { getScaleFilter, StreamType } from '../ffmpeg-utils'
6
7export async function presetVOD (options: {
8 commandWrapper: FFmpegCommandWrapper
9
10 input: string
11
12 canCopyAudio: boolean
13 canCopyVideo: boolean
14
15 resolution: number
16 fps: number
17
18 scaleFilterValue?: string
19}) {
20 const { commandWrapper, input, resolution, fps, scaleFilterValue } = options
21 const command = commandWrapper.getCommand()
22
23 command.format('mp4')
24 .outputOption('-movflags faststart')
25
26 addDefaultEncoderGlobalParams(command)
27
28 const probe = await ffprobePromise(input)
29
30 // Audio encoder
31 const bitrate = await getVideoStreamBitrate(input, probe)
32 const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
33
34 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
35
36 if (!await hasAudioStream(input, probe)) {
37 command.noAudio()
38 streamsToProcess = [ 'video' ]
39 }
40
41 for (const streamType of streamsToProcess) {
42 const builderResult = await commandWrapper.getEncoderBuilderResult({
43 ...pick(options, [ 'canCopyAudio', 'canCopyVideo' ]),
44
45 input,
46 inputBitrate: bitrate,
47 inputRatio: videoStreamDimensions?.ratio || 0,
48
49 resolution,
50 fps,
51 streamType,
52
53 videoType: 'vod' as 'vod'
54 })
55
56 if (!builderResult) {
57 throw new Error('No available encoder found for stream ' + streamType)
58 }
59
60 commandWrapper.debugLog(
61 `Apply ffmpeg params from ${builderResult.encoder} for ${streamType} ` +
62 `stream of input ${input} using ${commandWrapper.getProfile()} profile.`,
63 { builderResult, resolution, fps }
64 )
65
66 if (streamType === 'video') {
67 command.videoCodec(builderResult.encoder)
68
69 if (scaleFilterValue) {
70 command.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
71 }
72 } else if (streamType === 'audio') {
73 command.audioCodec(builderResult.encoder)
74 }
75
76 applyEncoderOptions(command, builderResult.result)
77 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps })
78 }
79}
80
81export function presetCopy (commandWrapper: FFmpegCommandWrapper) {
82 commandWrapper.getCommand()
83 .format('mp4')
84 .videoCodec('copy')
85 .audioCodec('copy')
86}
87
88export function presetOnlyAudio (commandWrapper: FFmpegCommandWrapper) {
89 commandWrapper.getCommand()
90 .format('mp4')
91 .audioCodec('copy')
92 .noVideo()
93}