]>
Commit | Line | Data |
---|---|---|
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 | } |