diff options
Diffstat (limited to 'packages/ffmpeg')
-rw-r--r-- | packages/ffmpeg/package.json | 19 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-command-wrapper.ts | 246 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-default-transcoding-profile.ts | 187 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-edition.ts | 239 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-images.ts | 92 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-live.ts | 184 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-utils.ts | 17 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-version.ts | 24 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffmpeg-vod.ts | 256 | ||||
-rw-r--r-- | packages/ffmpeg/src/ffprobe.ts | 184 | ||||
-rw-r--r-- | packages/ffmpeg/src/index.ts | 9 | ||||
-rw-r--r-- | packages/ffmpeg/src/shared/encoder-options.ts | 39 | ||||
-rw-r--r-- | packages/ffmpeg/src/shared/index.ts | 2 | ||||
-rw-r--r-- | packages/ffmpeg/src/shared/presets.ts | 93 | ||||
-rw-r--r-- | packages/ffmpeg/tsconfig.json | 12 |
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 @@ | |||
1 | import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { pick, promisify0 } from '@peertube/peertube-core-utils' | ||
3 | import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@peertube/peertube-models' | ||
4 | |||
5 | type 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 | |||
12 | export 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 | |||
28 | export 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 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { getAverageTheoreticalBitrate, getMaxTheoreticalBitrate, getMinTheoreticalBitrate } from '@peertube/peertube-core-utils' | ||
3 | import { | ||
4 | buildStreamSuffix, | ||
5 | ffprobePromise, | ||
6 | getAudioStream, | ||
7 | getMaxAudioBitrate, | ||
8 | getVideoStream, | ||
9 | getVideoStreamBitrate, | ||
10 | getVideoStreamDimensionsInfo, | ||
11 | getVideoStreamFPS | ||
12 | } from '@peertube/peertube-ffmpeg' | ||
13 | import { EncoderOptionsBuilder, EncoderOptionsBuilderParams } from '@peertube/peertube-models' | ||
14 | |||
15 | const 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 | |||
29 | const 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 | |||
44 | const 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 | |||
70 | const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum }) => { | ||
71 | return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] } | ||
72 | } | ||
73 | |||
74 | export 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 | |||
98 | export 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 | |||
112 | export 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 | |||
132 | export 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 | |||
153 | function 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 | |||
167 | function 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 | |||
176 | function 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 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' | ||
3 | import { presetVOD } from './shared/presets.js' | ||
4 | import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe.js' | ||
5 | |||
6 | export 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 @@ | |||
1 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' | ||
2 | import { getVideoStreamDuration } from './ffprobe.js' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { FilterSpecification } from 'fluent-ffmpeg' | ||
2 | import { join } from 'path' | ||
3 | import { pick } from '@peertube/peertube-core-utils' | ||
4 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' | ||
5 | import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-utils.js' | ||
6 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './shared/index.js' | ||
7 | |||
8 | export 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 @@ | |||
1 | import { EncoderOptions } from '@peertube/peertube-models' | ||
2 | |||
3 | export type StreamType = 'audio' | 'video' | ||
4 | |||
5 | export function buildStreamSuffix (base: string, streamNum?: number) { | ||
6 | if (streamNum !== undefined) { | ||
7 | return `${base}:${streamNum}` | ||
8 | } | ||
9 | |||
10 | return base | ||
11 | } | ||
12 | |||
13 | export 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 @@ | |||
1 | import { exec } from 'child_process' | ||
2 | import ffmpeg from 'fluent-ffmpeg' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { MutexInterface } from 'async-mutex' | ||
2 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
3 | import { readFile, writeFile } from 'fs/promises' | ||
4 | import { dirname } from 'path' | ||
5 | import { pick } from '@peertube/peertube-core-utils' | ||
6 | import { VideoResolution } from '@peertube/peertube-models' | ||
7 | import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper.js' | ||
8 | import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe.js' | ||
9 | import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets.js' | ||
10 | |||
11 | export type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | ||
12 | |||
13 | export 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 | |||
27 | export interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | ||
28 | type: 'hls' | ||
29 | |||
30 | copyCodecs: boolean | ||
31 | |||
32 | hlsPlaylist: { | ||
33 | videoFilename: string | ||
34 | } | ||
35 | } | ||
36 | |||
37 | export interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | ||
38 | type: 'hls-from-ts' | ||
39 | |||
40 | isAAC: boolean | ||
41 | |||
42 | hlsPlaylist: { | ||
43 | videoFilename: string | ||
44 | } | ||
45 | } | ||
46 | |||
47 | export interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | ||
48 | type: 'quick-transcode' | ||
49 | } | ||
50 | |||
51 | export interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | ||
52 | type: 'video' | ||
53 | } | ||
54 | |||
55 | export interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
56 | type: 'merge-audio' | ||
57 | audioPath: string | ||
58 | } | ||
59 | |||
60 | export interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | ||
61 | type: 'only-audio' | ||
62 | } | ||
63 | |||
64 | export type TranscodeVODOptions = | ||
65 | HLSTranscodeOptions | ||
66 | | HLSFromTSTranscodeOptions | ||
67 | | VideoTranscodeOptions | ||
68 | | MergeAudioTranscodeOptions | ||
69 | | OnlyAudioTranscodeOptions | ||
70 | | QuickTranscodeOptions | ||
71 | |||
72 | // --------------------------------------------------------------------------- | ||
73 | |||
74 | export 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 @@ | |||
1 | import ffmpeg, { FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { forceNumber } from '@peertube/peertube-core-utils' | ||
3 | import { VideoResolution } from '@peertube/peertube-models' | ||
4 | |||
5 | /** | ||
6 | * | ||
7 | * Helpers to run ffprobe and extract data from the JSON output | ||
8 | * | ||
9 | */ | ||
10 | |||
11 | function 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 | |||
25 | const 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 | |||
31 | async 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 | |||
40 | async function hasAudioStream (path: string, existingProbe?: FfprobeData) { | ||
41 | const { audioStream } = await getAudioStream(path, existingProbe) | ||
42 | |||
43 | return !!audioStream | ||
44 | } | ||
45 | |||
46 | async 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 | |||
67 | function 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 | |||
105 | async 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 | |||
126 | async 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 | |||
144 | async 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 | |||
159 | async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) { | ||
160 | const metadata = existingProbe || await ffprobePromise(path) | ||
161 | |||
162 | return Math.round(metadata.format.duration) | ||
163 | } | ||
164 | |||
165 | async 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 | |||
173 | export { | ||
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 @@ | |||
1 | export * from './ffmpeg-command-wrapper.js' | ||
2 | export * from './ffmpeg-default-transcoding-profile.js' | ||
3 | export * from './ffmpeg-edition.js' | ||
4 | export * from './ffmpeg-images.js' | ||
5 | export * from './ffmpeg-live.js' | ||
6 | export * from './ffmpeg-utils.js' | ||
7 | export * from './ffmpeg-version.js' | ||
8 | export * from './ffmpeg-vod.js' | ||
9 | export * 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 @@ | |||
1 | import { FfmpegCommand } from 'fluent-ffmpeg' | ||
2 | import { EncoderOptions } from '@peertube/peertube-models' | ||
3 | import { buildStreamSuffix } from '../ffmpeg-utils.js' | ||
4 | |||
5 | export 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 | |||
14 | export 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 | |||
36 | export 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 @@ | |||
1 | export * from './encoder-options.js' | ||
2 | export * 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 @@ | |||
1 | import { pick } from '@peertube/peertube-core-utils' | ||
2 | import { FFmpegCommandWrapper } from '../ffmpeg-command-wrapper.js' | ||
3 | import { getScaleFilter, StreamType } from '../ffmpeg-utils.js' | ||
4 | import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from '../ffprobe.js' | ||
5 | import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './encoder-options.js' | ||
6 | |||
7 | export 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 | |||
81 | export function presetCopy (commandWrapper: FFmpegCommandWrapper) { | ||
82 | commandWrapper.getCommand() | ||
83 | .format('mp4') | ||
84 | .videoCodec('copy') | ||
85 | .audioCodec('copy') | ||
86 | } | ||
87 | |||
88 | export 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 | } | ||