]>
Commit | Line | Data |
---|---|---|
c729caf6 C |
1 | import { Job } from 'bull' |
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 { AvailableEncoders, VideoResolution } from '@shared/models' | |
7 | import { logger, loggerTagsFactory } from '../logger' | |
8 | import { getFFmpeg, runCommand } from './ffmpeg-commons' | |
9 | import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets' | |
10 | import { computeFPS, getVideoStreamFPS } from './ffprobe-utils' | |
11 | import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants' | |
12 | ||
13 | const lTags = loggerTagsFactory('ffmpeg') | |
14 | ||
15 | // --------------------------------------------------------------------------- | |
16 | ||
17 | type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio' | |
18 | ||
19 | interface BaseTranscodeVODOptions { | |
20 | type: TranscodeVODOptionsType | |
21 | ||
22 | inputPath: string | |
23 | outputPath: string | |
24 | ||
25 | availableEncoders: AvailableEncoders | |
26 | profile: string | |
27 | ||
28 | resolution: number | |
29 | ||
30 | isPortraitMode?: boolean | |
31 | ||
32 | job?: Job | |
33 | } | |
34 | ||
35 | interface HLSTranscodeOptions extends BaseTranscodeVODOptions { | |
36 | type: 'hls' | |
37 | copyCodecs: boolean | |
38 | hlsPlaylist: { | |
39 | videoFilename: string | |
40 | } | |
41 | } | |
42 | ||
43 | interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions { | |
44 | type: 'hls-from-ts' | |
45 | ||
46 | isAAC: boolean | |
47 | ||
48 | hlsPlaylist: { | |
49 | videoFilename: string | |
50 | } | |
51 | } | |
52 | ||
53 | interface QuickTranscodeOptions extends BaseTranscodeVODOptions { | |
54 | type: 'quick-transcode' | |
55 | } | |
56 | ||
57 | interface VideoTranscodeOptions extends BaseTranscodeVODOptions { | |
58 | type: 'video' | |
59 | } | |
60 | ||
61 | interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions { | |
62 | type: 'merge-audio' | |
63 | audioPath: string | |
64 | } | |
65 | ||
66 | interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions { | |
67 | type: 'only-audio' | |
68 | } | |
69 | ||
70 | type TranscodeVODOptions = | |
71 | HLSTranscodeOptions | |
72 | | HLSFromTSTranscodeOptions | |
73 | | VideoTranscodeOptions | |
74 | | MergeAudioTranscodeOptions | |
75 | | OnlyAudioTranscodeOptions | |
76 | | QuickTranscodeOptions | |
77 | ||
78 | // --------------------------------------------------------------------------- | |
79 | ||
80 | const builders: { | |
81 | [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand | |
82 | } = { | |
83 | 'quick-transcode': buildQuickTranscodeCommand, | |
84 | 'hls': buildHLSVODCommand, | |
85 | 'hls-from-ts': buildHLSVODFromTSCommand, | |
86 | 'merge-audio': buildAudioMergeCommand, | |
87 | 'only-audio': buildOnlyAudioCommand, | |
88 | 'video': buildVODCommand | |
89 | } | |
90 | ||
91 | async function transcodeVOD (options: TranscodeVODOptions) { | |
92 | logger.debug('Will run transcode.', { options, ...lTags() }) | |
93 | ||
94 | let command = getFFmpeg(options.inputPath, 'vod') | |
95 | .output(options.outputPath) | |
96 | ||
97 | command = await builders[options.type](command, options) | |
98 | ||
99 | await runCommand({ command, job: options.job }) | |
100 | ||
101 | await fixHLSPlaylistIfNeeded(options) | |
102 | } | |
103 | ||
104 | // --------------------------------------------------------------------------- | |
105 | ||
106 | export { | |
107 | transcodeVOD, | |
108 | ||
109 | buildVODCommand, | |
110 | ||
111 | TranscodeVODOptions, | |
112 | TranscodeVODOptionsType | |
113 | } | |
114 | ||
115 | // --------------------------------------------------------------------------- | |
116 | ||
117 | async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) { | |
118 | let fps = await getVideoStreamFPS(options.inputPath) | |
119 | fps = computeFPS(fps, options.resolution) | |
120 | ||
121 | let scaleFilterValue: string | |
122 | ||
123 | if (options.resolution !== undefined) { | |
124 | scaleFilterValue = options.isPortraitMode === true | |
125 | ? `w=${options.resolution}:h=-2` | |
126 | : `w=-2:h=${options.resolution}` | |
127 | } | |
128 | ||
129 | command = await presetVOD({ | |
130 | ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]), | |
131 | ||
132 | command, | |
133 | input: options.inputPath, | |
134 | canCopyAudio: true, | |
135 | canCopyVideo: true, | |
136 | fps, | |
137 | scaleFilterValue | |
138 | }) | |
139 | ||
140 | return command | |
141 | } | |
142 | ||
143 | function buildQuickTranscodeCommand (command: FfmpegCommand) { | |
144 | command = presetCopy(command) | |
145 | ||
146 | command = command.outputOption('-map_metadata -1') // strip all metadata | |
147 | .outputOption('-movflags faststart') | |
148 | ||
149 | return command | |
150 | } | |
151 | ||
152 | // --------------------------------------------------------------------------- | |
153 | // Audio transcoding | |
154 | // --------------------------------------------------------------------------- | |
155 | ||
156 | async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) { | |
157 | command = command.loop(undefined) | |
158 | ||
159 | const scaleFilterValue = getMergeAudioScaleFilterValue() | |
160 | command = await presetVOD({ | |
161 | ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]), | |
162 | ||
163 | command, | |
164 | input: options.audioPath, | |
165 | canCopyAudio: true, | |
166 | canCopyVideo: true, | |
167 | fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE, | |
168 | scaleFilterValue | |
169 | }) | |
170 | ||
171 | command.outputOption('-preset:v veryfast') | |
172 | ||
173 | command = command.input(options.audioPath) | |
174 | .outputOption('-tune stillimage') | |
175 | .outputOption('-shortest') | |
176 | ||
177 | return command | |
178 | } | |
179 | ||
180 | function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) { | |
181 | command = presetOnlyAudio(command) | |
182 | ||
183 | return command | |
184 | } | |
185 | ||
186 | // --------------------------------------------------------------------------- | |
187 | // HLS transcoding | |
188 | // --------------------------------------------------------------------------- | |
189 | ||
190 | async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) { | |
191 | const videoPath = getHLSVideoPath(options) | |
192 | ||
193 | if (options.copyCodecs) command = presetCopy(command) | |
194 | else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command) | |
195 | else command = await buildVODCommand(command, options) | |
196 | ||
197 | addCommonHLSVODCommandOptions(command, videoPath) | |
198 | ||
199 | return command | |
200 | } | |
201 | ||
202 | function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) { | |
203 | const videoPath = getHLSVideoPath(options) | |
204 | ||
205 | command.outputOption('-c copy') | |
206 | ||
207 | if (options.isAAC) { | |
208 | // Required for example when copying an AAC stream from an MPEG-TS | |
209 | // Since it's a bitstream filter, we don't need to reencode the audio | |
210 | command.outputOption('-bsf:a aac_adtstoasc') | |
211 | } | |
212 | ||
213 | addCommonHLSVODCommandOptions(command, videoPath) | |
214 | ||
215 | return command | |
216 | } | |
217 | ||
218 | function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) { | |
219 | return command.outputOption('-hls_time 4') | |
220 | .outputOption('-hls_list_size 0') | |
221 | .outputOption('-hls_playlist_type vod') | |
222 | .outputOption('-hls_segment_filename ' + outputPath) | |
223 | .outputOption('-hls_segment_type fmp4') | |
224 | .outputOption('-f hls') | |
225 | .outputOption('-hls_flags single_file') | |
226 | } | |
227 | ||
228 | async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) { | |
229 | if (options.type !== 'hls' && options.type !== 'hls-from-ts') return | |
230 | ||
231 | const fileContent = await readFile(options.outputPath) | |
232 | ||
233 | const videoFileName = options.hlsPlaylist.videoFilename | |
234 | const videoFilePath = getHLSVideoPath(options) | |
235 | ||
236 | // Fix wrong mapping with some ffmpeg versions | |
237 | const newContent = fileContent.toString() | |
238 | .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) | |
239 | ||
240 | await writeFile(options.outputPath, newContent) | |
241 | } | |
242 | ||
243 | // --------------------------------------------------------------------------- | |
244 | // Helpers | |
245 | // --------------------------------------------------------------------------- | |
246 | ||
247 | function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) { | |
248 | return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` | |
249 | } | |
250 | ||
251 | // Avoid "height not divisible by 2" error | |
252 | function getMergeAudioScaleFilterValue () { | |
253 | return 'trunc(iw/2)*2:trunc(ih/2)*2' | |
254 | } |