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