]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - shared/ffmpeg/ffmpeg-vod.ts
Implement remote runner jobs in server
[github/Chocobozzz/PeerTube.git] / shared / ffmpeg / ffmpeg-vod.ts
1 import { MutexInterface } from 'async-mutex'
2 import { FfmpegCommand } from 'fluent-ffmpeg'
3 import { readFile, writeFile } from 'fs-extra'
4 import { dirname } from 'path'
5 import { pick } from '@shared/core-utils'
6 import { VideoResolution } from '@shared/models'
7 import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
8 import { ffprobePromise, getVideoStreamDimensionsInfo } from './ffprobe'
9 import { presetCopy, presetOnlyAudio, presetVOD } from './shared/presets'
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 }