aboutsummaryrefslogtreecommitdiffhomepage
path: root/packages/ffmpeg
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-31 14:34:36 +0200
committerChocobozzz <me@florianbigard.com>2023-08-11 15:02:33 +0200
commit3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch)
treee4510b39bdac9c318fdb4b47018d08f15368b8f0 /packages/ffmpeg
parent04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff)
downloadPeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.gz
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.zst
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.zip
Migrate server to ESM
Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports)
Diffstat (limited to 'packages/ffmpeg')
-rw-r--r--packages/ffmpeg/package.json19
-rw-r--r--packages/ffmpeg/src/ffmpeg-command-wrapper.ts246
-rw-r--r--packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts187
-rw-r--r--packages/ffmpeg/src/ffmpeg-edition.ts239
-rw-r--r--packages/ffmpeg/src/ffmpeg-images.ts92
-rw-r--r--packages/ffmpeg/src/ffmpeg-live.ts184
-rw-r--r--packages/ffmpeg/src/ffmpeg-utils.ts17
-rw-r--r--packages/ffmpeg/src/ffmpeg-version.ts24
-rw-r--r--packages/ffmpeg/src/ffmpeg-vod.ts256
-rw-r--r--packages/ffmpeg/src/ffprobe.ts184
-rw-r--r--packages/ffmpeg/src/index.ts9
-rw-r--r--packages/ffmpeg/src/shared/encoder-options.ts39
-rw-r--r--packages/ffmpeg/src/shared/index.ts2
-rw-r--r--packages/ffmpeg/src/shared/presets.ts93
-rw-r--r--packages/ffmpeg/tsconfig.json12
15 files changed, 1603 insertions, 0 deletions
diff --git a/packages/ffmpeg/package.json b/packages/ffmpeg/package.json
new file mode 100644
index 000000000..fca86df25
--- /dev/null
+++ b/packages/ffmpeg/package.json
@@ -0,0 +1,19 @@
1{
2 "name": "@peertube/peertube-ffmpeg",
3 "private": true,
4 "version": "0.0.0",
5 "main": "dist/index.js",
6 "files": [ "dist" ],
7 "exports": {
8 "types": "./dist/index.d.ts",
9 "peertube:tsx": "./src/index.ts",
10 "default": "./dist/index.js"
11 },
12 "type": "module",
13 "devDependencies": {},
14 "scripts": {
15 "build": "tsc",
16 "watch": "tsc -w"
17 },
18 "dependencies": {}
19}
diff --git a/packages/ffmpeg/src/ffmpeg-command-wrapper.ts b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts
new file mode 100644
index 000000000..647ee3996
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-command-wrapper.ts
@@ -0,0 +1,246 @@
1import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
2import { pick, promisify0 } from '@peertube/peertube-core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@peertube/peertube-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 onEnd?: () => void
25 onError?: (err: Error) => void
26}
27
28export class FFmpegCommandWrapper {
29 private static supportedEncoders: Map<string, boolean>
30
31 private readonly availableEncoders: AvailableEncoders
32 private readonly profile: string
33
34 private readonly niceness: number
35 private readonly tmpDirectory: string
36 private readonly threads: number
37
38 private readonly logger: FFmpegLogger
39 private readonly lTags: { tags: string[] }
40
41 private readonly updateJobProgress: (progress?: number) => void
42 private readonly onEnd?: () => void
43 private readonly onError?: (err: Error) => void
44
45 private command: FfmpegCommand
46
47 constructor (options: FFmpegCommandWrapperOptions) {
48 this.availableEncoders = options.availableEncoders
49 this.profile = options.profile
50 this.niceness = options.niceness
51 this.tmpDirectory = options.tmpDirectory
52 this.threads = options.threads
53 this.logger = options.logger
54 this.lTags = options.lTags || { tags: [] }
55
56 this.updateJobProgress = options.updateJobProgress
57
58 this.onEnd = options.onEnd
59 this.onError = options.onError
60 }
61
62 getAvailableEncoders () {
63 return this.availableEncoders
64 }
65
66 getProfile () {
67 return this.profile
68 }
69
70 getCommand () {
71 return this.command
72 }
73
74 // ---------------------------------------------------------------------------
75
76 debugLog (msg: string, meta: any) {
77 this.logger.debug(msg, { ...meta, ...this.lTags })
78 }
79
80 // ---------------------------------------------------------------------------
81
82 buildCommand (input: string) {
83 if (this.command) throw new Error('Command is already built')
84
85 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
86 this.command = ffmpeg(input, {
87 niceness: this.niceness,
88 cwd: this.tmpDirectory
89 })
90
91 if (this.threads > 0) {
92 // If we don't set any threads ffmpeg will chose automatically
93 this.command.outputOption('-threads ' + this.threads)
94 }
95
96 return this.command
97 }
98
99 async runCommand (options: {
100 silent?: boolean // false by default
101 } = {}) {
102 const { silent = false } = options
103
104 return new Promise<void>((res, rej) => {
105 let shellCommand: string
106
107 this.command.on('start', cmdline => { shellCommand = cmdline })
108
109 this.command.on('error', (err, stdout, stderr) => {
110 if (silent !== true) this.logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...this.lTags })
111
112 if (this.onError) this.onError(err)
113
114 rej(err)
115 })
116
117 this.command.on('end', (stdout, stderr) => {
118 this.logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...this.lTags })
119
120 if (this.onEnd) this.onEnd()
121
122 res()
123 })
124
125 if (this.updateJobProgress) {
126 this.command.on('progress', progress => {
127 if (!progress.percent) return
128
129 // Sometimes ffmpeg returns an invalid progress
130 let percent = Math.round(progress.percent)
131 if (percent < 0) percent = 0
132 if (percent > 100) percent = 100
133
134 this.updateJobProgress(percent)
135 })
136 }
137
138 this.command.run()
139 })
140 }
141
142 // ---------------------------------------------------------------------------
143
144 static resetSupportedEncoders () {
145 FFmpegCommandWrapper.supportedEncoders = undefined
146 }
147
148 // Run encoder builder depending on available encoders
149 // Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
150 // If the default one does not exist, check the next encoder
151 async getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
152 streamType: 'video' | 'audio'
153 input: string
154
155 videoType: 'vod' | 'live'
156 }) {
157 if (!this.availableEncoders) {
158 throw new Error('There is no available encoders')
159 }
160
161 const { streamType, videoType } = options
162
163 const encodersToTry = this.availableEncoders.encodersToTry[videoType][streamType]
164 const encoders = this.availableEncoders.available[videoType]
165
166 for (const encoder of encodersToTry) {
167 if (!(await this.checkFFmpegEncoders(this.availableEncoders)).get(encoder)) {
168 this.logger.debug(`Encoder ${encoder} not available in ffmpeg, skipping.`, this.lTags)
169 continue
170 }
171
172 if (!encoders[encoder]) {
173 this.logger.debug(`Encoder ${encoder} not available in peertube encoders, skipping.`, this.lTags)
174 continue
175 }
176
177 // An object containing available profiles for this encoder
178 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
179 let builder = builderProfiles[this.profile]
180
181 if (!builder) {
182 this.logger.debug(`Profile ${this.profile} for encoder ${encoder} not available. Fallback to default.`, this.lTags)
183 builder = builderProfiles.default
184
185 if (!builder) {
186 this.logger.debug(`Default profile for encoder ${encoder} not available. Try next available encoder.`, this.lTags)
187 continue
188 }
189 }
190
191 const result = await builder(
192 pick(options, [
193 'input',
194 'canCopyAudio',
195 'canCopyVideo',
196 'resolution',
197 'inputBitrate',
198 'fps',
199 'inputRatio',
200 'streamNum'
201 ])
202 )
203
204 return {
205 result,
206
207 // If we don't have output options, then copy the input stream
208 encoder: result.copy === true
209 ? 'copy'
210 : encoder
211 }
212 }
213
214 return null
215 }
216
217 // Detect supported encoders by ffmpeg
218 private async checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
219 if (FFmpegCommandWrapper.supportedEncoders !== undefined) {
220 return FFmpegCommandWrapper.supportedEncoders
221 }
222
223 const getAvailableEncodersPromise = promisify0(ffmpeg.getAvailableEncoders)
224 const availableFFmpegEncoders = await getAvailableEncodersPromise()
225
226 const searchEncoders = new Set<string>()
227 for (const type of [ 'live', 'vod' ]) {
228 for (const streamType of [ 'audio', 'video' ]) {
229 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
230 searchEncoders.add(encoder)
231 }
232 }
233 }
234
235 const supportedEncoders = new Map<string, boolean>()
236
237 for (const searchEncoder of searchEncoders) {
238 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
239 }
240
241 this.logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...this.lTags })
242
243 FFmpegCommandWrapper.supportedEncoders = supportedEncoders
244 return supportedEncoders
245 }
246}
diff --git a/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts b/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts
new file mode 100644
index 000000000..0d3538512
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts
@@ -0,0 +1,187 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils'
3import {
4 buildStreamSuffix,
5 ffprobePromise,
6 getAudioStream,
7 getMaxAudioBitrate,
8 getVideoStream,
9 getVideoStreamBitrate,
10 getVideoStreamDimensionsInfo,
11 getVideoStreamFPS
12} from '@peertube/peertube-ffmpeg'
13import { EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '@peertube/peertube-models'
14
15const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
16 const { fps, inputRatio, inputBitrate, resolution } = options
17
18 const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
19
20 return {
21 outputOptions: [
22 ...getCommonOutputOptions(targetBitrate),
23
24 `-r ${fps}`
25 ]
26 }
27}
28
29const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
30 const { streamNum, fps, inputBitrate, inputRatio, resolution } = options
31
32 const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
33
34 return {
35 outputOptions: [
36 ...getCommonOutputOptions(targetBitrate, streamNum),
37
38 `${buildStreamSuffix('-r:v', streamNum)} ${fps}`,
39 `${buildStreamSuffix('-b:v', streamNum)} ${targetBitrate}`
40 ]
41 }
42}
43
44const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => {
45 const probe = await ffprobePromise(input)
46
47 if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) {
48 return { copy: true, outputOptions: [ ] }
49 }
50
51 const parsedAudio = await getAudioStream(input, probe)
52
53 // We try to reduce the ceiling bitrate by making rough matches of bitrates
54 // Of course this is far from perfect, but it might save some space in the end
55
56 const audioCodecName = parsedAudio.audioStream['codec_name']
57
58 const bitrate = getMaxAudioBitrate(audioCodecName, parsedAudio.bitrate)
59
60 // Force stereo as it causes some issues with HLS playback in Chrome
61 const base = [ '-channel_layout', 'stereo' ]
62
63 if (bitrate !== -1) {
64 return { outputOptions: base.concat([ buildStreamSuffix('-b:a', streamNum), bitrate + 'k' ]) }
65 }
66
67 return { outputOptions: base }
68}
69
70const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum }) => {
71 return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
72}
73
74export function getDefaultAvailableEncoders () {
75 return {
76 vod: {
77 libx264: {
78 default: defaultX264VODOptionsBuilder
79 },
80 aac: {
81 default: defaultAACOptionsBuilder
82 },
83 libfdk_aac: {
84 default: defaultLibFDKAACVODOptionsBuilder
85 }
86 },
87 live: {
88 libx264: {
89 default: defaultX264LiveOptionsBuilder
90 },
91 aac: {
92 default: defaultAACOptionsBuilder
93 }
94 }
95 }
96}
97
98export function getDefaultEncodersToTry () {
99 return {
100 vod: {
101 video: [ 'libx264' ],
102 audio: [ 'libfdk_aac', 'aac' ]
103 },
104
105 live: {
106 video: [ 'libx264' ],
107 audio: [ 'libfdk_aac', 'aac' ]
108 }
109 }
110}
111
112export async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
113 const parsedAudio = await getAudioStream(path, probe)
114
115 if (!parsedAudio.audioStream) return true
116
117 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
118
119 const audioBitrate = parsedAudio.bitrate
120 if (!audioBitrate) return false
121
122 const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
123 if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
124
125 const channelLayout = parsedAudio.audioStream['channel_layout']
126 // Causes playback issues with Chrome
127 if (!channelLayout || channelLayout === 'unknown' || channelLayout === 'quad') return false
128
129 return true
130}
131
132export async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
133 const videoStream = await getVideoStream(path, probe)
134 const fps = await getVideoStreamFPS(path, probe)
135 const bitRate = await getVideoStreamBitrate(path, probe)
136 const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
137
138 // If ffprobe did not manage to guess the bitrate
139 if (!bitRate) return false
140
141 // check video params
142 if (!videoStream) return false
143 if (videoStream['codec_name'] !== 'h264') return false
144 if (videoStream['pix_fmt'] !== 'yuv420p') return false
145 if (fps < 2 || fps > 65) return false
146 if (bitRate > getMaxTheoreticalBitrate({ ...resolutionData, fps })) return false
147
148 return true
149}
150
151// ---------------------------------------------------------------------------
152
153function getTargetBitrate (options: {
154 inputBitrate: number
155 resolution: number
156 ratio: number
157 fps: number
158}) {
159 const { inputBitrate, resolution, ratio, fps } = options
160
161 const capped = capBitrate(inputBitrate, getAverageTheoreticalBitrate({ resolution, fps, ratio }))
162 const limit = getMinTheoreticalBitrate({ resolution, fps, ratio })
163
164 return Math.max(limit, capped)
165}
166
167function capBitrate (inputBitrate: number, targetBitrate: number) {
168 if (!inputBitrate) return targetBitrate
169
170 // Add 30% margin to input bitrate
171 const inputBitrateWithMargin = inputBitrate + (inputBitrate * 0.3)
172
173 return Math.min(targetBitrate, inputBitrateWithMargin)
174}
175
176function getCommonOutputOptions (targetBitrate: number, streamNum?: number) {
177 return [
178 `-preset veryfast`,
179 `${buildStreamSuffix('-maxrate:v', streamNum)} ${targetBitrate}`,
180 `${buildStreamSuffix('-bufsize:v', streamNum)} ${targetBitrate * 2}`,
181
182 // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
183 `-b_strategy 1`,
184 // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
185 `-bf 16`
186 ]
187}
diff --git a/packages/ffmpeg/src/ffmpeg-edition.ts b/packages/ffmpeg/src/ffmpeg-edition.ts
new file mode 100644
index 000000000..021342930
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-edition.ts
@@ -0,0 +1,239 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
3import { presetVOD } from './shared/presets.js'
4import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js'
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/packages/ffmpeg/src/ffmpeg-images.ts b/packages/ffmpeg/src/ffmpeg-images.ts
new file mode 100644
index 000000000..4cd37aa80
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-images.ts
@@ -0,0 +1,92 @@
1import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
2import { getVideoStreamDuration } from './ffprobe.js'
3
4export class FFmpegImage {
5 private readonly commandWrapper: FFmpegCommandWrapper
6
7 constructor (options: FFmpegCommandWrapperOptions) {
8 this.commandWrapper = new FFmpegCommandWrapper(options)
9 }
10
11 convertWebPToJPG (options: {
12 path: string
13 destination: string
14 }): Promise<void> {
15 const { path, destination } = options
16
17 this.commandWrapper.buildCommand(path)
18 .output(destination)
19
20 return this.commandWrapper.runCommand({ silent: true })
21 }
22
23 processGIF (options: {
24 path: string
25 destination: string
26 newSize: { width: number, height: number }
27 }): Promise<void> {
28 const { path, destination, newSize } = options
29
30 this.commandWrapper.buildCommand(path)
31 .fps(20)
32 .size(`${newSize.width}x${newSize.height}`)
33 .output(destination)
34
35 return this.commandWrapper.runCommand()
36 }
37
38 async generateThumbnailFromVideo (options: {
39 fromPath: string
40 output: string
41 }) {
42 const { fromPath, output } = options
43
44 let duration = await getVideoStreamDuration(fromPath)
45 if (isNaN(duration)) duration = 0
46
47 this.commandWrapper.buildCommand(fromPath)
48 .seekInput(duration / 2)
49 .videoFilter('thumbnail=500')
50 .outputOption('-frames:v 1')
51 .output(output)
52
53 return this.commandWrapper.runCommand()
54 }
55
56 async generateStoryboardFromVideo (options: {
57 path: string
58 destination: string
59
60 sprites: {
61 size: {
62 width: number
63 height: number
64 }
65
66 count: {
67 width: number
68 height: number
69 }
70
71 duration: number
72 }
73 }) {
74 const { path, destination, sprites } = options
75
76 const command = this.commandWrapper.buildCommand(path)
77
78 const filter = [
79 `setpts=N/round(FRAME_RATE)/TB`,
80 `select='not(mod(t,${options.sprites.duration}))'`,
81 `scale=${sprites.size.width}:${sprites.size.height}`,
82 `tile=layout=${sprites.count.width}x${sprites.count.height}`
83 ].join(',')
84
85 command.outputOption('-filter_complex', filter)
86 command.outputOption('-frames:v', '1')
87 command.outputOption('-q:v', '2')
88 command.output(destination)
89
90 return this.commandWrapper.runCommand()
91 }
92}
diff --git a/packages/ffmpeg/src/ffmpeg-live.ts b/packages/ffmpeg/src/ffmpeg-live.ts
new file mode 100644
index 000000000..20318f63c
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-live.ts
@@ -0,0 +1,184 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { pick } from '@peertube/peertube-core-utils'
4import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils.js'
6import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js'
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/packages/ffmpeg/src/ffmpeg-utils.ts b/packages/ffmpeg/src/ffmpeg-utils.ts
new file mode 100644
index 000000000..56fd8c0b3
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-utils.ts
@@ -0,0 +1,17 @@
1import { EncoderOptions } from '@peertube/peertube-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/packages/ffmpeg/src/ffmpeg-version.ts b/packages/ffmpeg/src/ffmpeg-version.ts
new file mode 100644
index 000000000..41d9b2d89
--- /dev/null
+++ b/packages/ffmpeg/src/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/packages/ffmpeg/src/ffmpeg-vod.ts b/packages/ffmpeg/src/ffmpeg-vod.ts
new file mode 100644
index 000000000..6dd272b8d
--- /dev/null
+++ b/packages/ffmpeg/src/ffmpeg-vod.ts
@@ -0,0 +1,256 @@
1import { MutexInterface } from 'async-mutex'
2import { FfmpegCommand } from 'fluent-ffmpeg'
3import { readFile, writeFile } from 'fs/promises'
4import { dirname } from 'path'
5import { pick } from '@peertube/peertube-core-utils'
6import { VideoResolution } from '@peertube/peertube-models'
7import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js'
8import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js'
9import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js'
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/packages/ffmpeg/src/ffprobe.ts b/packages/ffmpeg/src/ffprobe.ts
new file mode 100644
index 000000000..ed1742ab1
--- /dev/null
+++ b/packages/ffmpeg/src/ffprobe.ts
@@ -0,0 +1,184 @@
1import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'
2import { forceNumber } from '@peertube/peertube-core-utils'
3import { VideoResolution } from '@peertube/peertube-models'
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 ffmpeg.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/packages/ffmpeg/src/index.ts b/packages/ffmpeg/src/index.ts
new file mode 100644
index 000000000..511409a50
--- /dev/null
+++ b/packages/ffmpeg/src/index.ts
@@ -0,0 +1,9 @@
1export * from './ffmpeg-command-wrapper.js'
2export * from './ffmpeg-default-transcoding-profile.js'
3export * from './ffmpeg-edition.js'
4export * from './ffmpeg-images.js'
5export * from './ffmpeg-live.js'
6export * from './ffmpeg-utils.js'
7export * from './ffmpeg-version.js'
8export * from './ffmpeg-vod.js'
9export * from './ffprobe.js'
diff --git a/packages/ffmpeg/src/shared/encoder-options.ts b/packages/ffmpeg/src/shared/encoder-options.ts
new file mode 100644
index 000000000..376a19186
--- /dev/null
+++ b/packages/ffmpeg/src/shared/encoder-options.ts
@@ -0,0 +1,39 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { EncoderOptions } from '@peertube/peertube-models'
3import { buildStreamSuffix } from '../ffmpeg-utils.js'
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/packages/ffmpeg/src/shared/index.ts b/packages/ffmpeg/src/shared/index.ts
new file mode 100644
index 000000000..81e8ff0b5
--- /dev/null
+++ b/packages/ffmpeg/src/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './encoder-options.js'
2export * from './presets.js'
diff --git a/packages/ffmpeg/src/shared/presets.ts b/packages/ffmpeg/src/shared/presets.ts
new file mode 100644
index 000000000..17bd7b031
--- /dev/null
+++ b/packages/ffmpeg/src/shared/presets.ts
@@ -0,0 +1,93 @@
1import { pick } from '@peertube/peertube-core-utils'
2import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper.js'
3import { getScaleFilter, StreamType } from '../ffmpeg-utils.js'
4import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe.js'
5import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options.js'
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}
diff --git a/packages/ffmpeg/tsconfig.json b/packages/ffmpeg/tsconfig.json
new file mode 100644
index 000000000..c8aeb3c14
--- /dev/null
+++ b/packages/ffmpeg/tsconfig.json
@@ -0,0 +1,12 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "outDir": "./dist",
5 "rootDir": "src",
6 "tsBuildInfoFile": "./dist/.tsbuildinfo"
7 },
8 "references": [
9 { "path": "../models" },
10 { "path": "../core-utils" }
11 ]
12}