]>
Commit | Line | Data |
---|---|---|
41fb13c3 | 1 | import { ffprobe, FfprobeData } from 'fluent-ffmpeg' |
679c12e6 C |
2 | import { getMaxBitrate } from '@shared/core-utils' |
3 | import { VideoFileMetadata, VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos' | |
daf6e480 C |
4 | import { CONFIG } from '../initializers/config' |
5 | import { VIDEO_TRANSCODING_FPS } from '../initializers/constants' | |
6 | import { logger } from './logger' | |
7 | ||
6b67897e C |
8 | /** |
9 | * | |
10 | * Helpers to run ffprobe and extract data from the JSON output | |
11 | * | |
12 | */ | |
13 | ||
daf6e480 | 14 | function ffprobePromise (path: string) { |
41fb13c3 C |
15 | return new Promise<FfprobeData>((res, rej) => { |
16 | ffprobe(path, (err, data) => { | |
daf6e480 C |
17 | if (err) return rej(err) |
18 | ||
19 | return res(data) | |
20 | }) | |
21 | }) | |
22 | } | |
23 | ||
41fb13c3 | 24 | async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) { |
daf6e480 C |
25 | // without position, ffprobe considers the last input only |
26 | // we make it consider the first input only | |
27 | // if you pass a file path to pos, then ffprobe acts on that file directly | |
28 | const data = existingProbe || await ffprobePromise(videoPath) | |
29 | ||
30 | if (Array.isArray(data.streams)) { | |
31 | const audioStream = data.streams.find(stream => stream['codec_type'] === 'audio') | |
32 | ||
33 | if (audioStream) { | |
34 | return { | |
35 | absolutePath: data.format.filename, | |
36 | audioStream, | |
37 | bitrate: parseInt(audioStream['bit_rate'] + '', 10) | |
38 | } | |
39 | } | |
40 | } | |
41 | ||
42 | return { absolutePath: data.format.filename } | |
43 | } | |
44 | ||
45 | function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) { | |
33ff70ba C |
46 | const maxKBitrate = 384 |
47 | const kToBits = (kbits: number) => kbits * 1000 | |
48 | ||
49 | // If we did not manage to get the bitrate, use an average value | |
50 | if (!bitrate) return 256 | |
daf6e480 C |
51 | |
52 | if (type === 'aac') { | |
53 | switch (true) { | |
33ff70ba C |
54 | case bitrate > kToBits(maxKBitrate): |
55 | return maxKBitrate | |
daf6e480 C |
56 | |
57 | default: | |
58 | return -1 // we interpret it as a signal to copy the audio stream as is | |
59 | } | |
60 | } | |
61 | ||
33ff70ba C |
62 | /* |
63 | a 192kbit/sec mp3 doesn't hold as much information as a 192kbit/sec aac. | |
64 | That's why, when using aac, we can go to lower kbit/sec. The equivalences | |
65 | made here are not made to be accurate, especially with good mp3 encoders. | |
66 | */ | |
67 | switch (true) { | |
68 | case bitrate <= kToBits(192): | |
69 | return 128 | |
daf6e480 | 70 | |
33ff70ba C |
71 | case bitrate <= kToBits(384): |
72 | return 256 | |
daf6e480 | 73 | |
33ff70ba C |
74 | default: |
75 | return maxKBitrate | |
daf6e480 | 76 | } |
daf6e480 C |
77 | } |
78 | ||
41fb13c3 | 79 | async function getVideoStreamSize (path: string, existingProbe?: FfprobeData): Promise<{ width: number, height: number }> { |
daf6e480 C |
80 | const videoStream = await getVideoStreamFromFile(path, existingProbe) |
81 | ||
82 | return videoStream === null | |
83 | ? { width: 0, height: 0 } | |
84 | : { width: videoStream.width, height: videoStream.height } | |
85 | } | |
86 | ||
87 | async function getVideoStreamCodec (path: string) { | |
88 | const videoStream = await getVideoStreamFromFile(path) | |
89 | ||
90 | if (!videoStream) return '' | |
91 | ||
92 | const videoCodec = videoStream.codec_tag_string | |
93 | ||
78995146 | 94 | if (videoCodec === 'vp09') return 'vp09.00.50.08' |
08370f62 | 95 | if (videoCodec === 'hev1') return 'hev1.1.6.L93.B0' |
78995146 | 96 | |
daf6e480 | 97 | const baseProfileMatrix = { |
78995146 C |
98 | avc1: { |
99 | High: '6400', | |
100 | Main: '4D40', | |
101 | Baseline: '42E0' | |
102 | }, | |
103 | av01: { | |
104 | High: '1', | |
105 | Main: '0', | |
106 | Professional: '2' | |
107 | } | |
daf6e480 C |
108 | } |
109 | ||
78995146 | 110 | let baseProfile = baseProfileMatrix[videoCodec][videoStream.profile] |
daf6e480 C |
111 | if (!baseProfile) { |
112 | logger.warn('Cannot get video profile codec of %s.', path, { videoStream }) | |
78995146 C |
113 | baseProfile = baseProfileMatrix[videoCodec]['High'] // Fallback |
114 | } | |
115 | ||
116 | if (videoCodec === 'av01') { | |
117 | const level = videoStream.level | |
118 | ||
119 | // Guess the tier indicator and bit depth | |
120 | return `${videoCodec}.${baseProfile}.${level}M.08` | |
daf6e480 C |
121 | } |
122 | ||
78995146 | 123 | // Default, h264 codec |
daf6e480 C |
124 | let level = videoStream.level.toString(16) |
125 | if (level.length === 1) level = `0${level}` | |
126 | ||
127 | return `${videoCodec}.${baseProfile}${level}` | |
128 | } | |
129 | ||
41fb13c3 | 130 | async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) { |
daf6e480 C |
131 | const { audioStream } = await getAudioStream(path, existingProbe) |
132 | ||
133 | if (!audioStream) return '' | |
134 | ||
78995146 C |
135 | const audioCodecName = audioStream.codec_name |
136 | ||
137 | if (audioCodecName === 'opus') return 'opus' | |
138 | if (audioCodecName === 'vorbis') return 'vorbis' | |
139 | if (audioCodecName === 'aac') return 'mp4a.40.2' | |
daf6e480 C |
140 | |
141 | logger.warn('Cannot get audio codec of %s.', path, { audioStream }) | |
142 | ||
143 | return 'mp4a.40.2' // Fallback | |
144 | } | |
145 | ||
41fb13c3 | 146 | async function getVideoFileResolution (path: string, existingProbe?: FfprobeData) { |
daf6e480 C |
147 | const size = await getVideoStreamSize(path, existingProbe) |
148 | ||
149 | return { | |
679c12e6 C |
150 | width: size.width, |
151 | height: size.height, | |
152 | ratio: Math.max(size.height, size.width) / Math.min(size.height, size.width), | |
153 | resolution: Math.min(size.height, size.width), | |
daf6e480 C |
154 | isPortraitMode: size.height > size.width |
155 | } | |
156 | } | |
157 | ||
41fb13c3 | 158 | async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) { |
daf6e480 C |
159 | const videoStream = await getVideoStreamFromFile(path, existingProbe) |
160 | if (videoStream === null) return 0 | |
161 | ||
162 | for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { | |
163 | const valuesText: string = videoStream[key] | |
164 | if (!valuesText) continue | |
165 | ||
166 | const [ frames, seconds ] = valuesText.split('/') | |
167 | if (!frames || !seconds) continue | |
168 | ||
169 | const result = parseInt(frames, 10) / parseInt(seconds, 10) | |
170 | if (result > 0) return Math.round(result) | |
171 | } | |
172 | ||
173 | return 0 | |
174 | } | |
175 | ||
41fb13c3 | 176 | async function getMetadataFromFile (path: string, existingProbe?: FfprobeData) { |
daf6e480 C |
177 | const metadata = existingProbe || await ffprobePromise(path) |
178 | ||
179 | return new VideoFileMetadata(metadata) | |
180 | } | |
181 | ||
41fb13c3 | 182 | async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData): Promise<number> { |
daf6e480 C |
183 | const metadata = await getMetadataFromFile(path, existingProbe) |
184 | ||
c826f34a C |
185 | let bitrate = metadata.format.bit_rate as number |
186 | if (bitrate && !isNaN(bitrate)) return bitrate | |
187 | ||
188 | const videoStream = await getVideoStreamFromFile(path, existingProbe) | |
189 | if (!videoStream) return undefined | |
190 | ||
191 | bitrate = videoStream?.bit_rate | |
192 | if (bitrate && !isNaN(bitrate)) return bitrate | |
193 | ||
194 | return undefined | |
daf6e480 C |
195 | } |
196 | ||
41fb13c3 | 197 | async function getDurationFromVideoFile (path: string, existingProbe?: FfprobeData) { |
daf6e480 C |
198 | const metadata = await getMetadataFromFile(path, existingProbe) |
199 | ||
4a54a939 | 200 | return Math.round(metadata.format.duration) |
daf6e480 C |
201 | } |
202 | ||
41fb13c3 | 203 | async function getVideoStreamFromFile (path: string, existingProbe?: FfprobeData) { |
daf6e480 C |
204 | const metadata = await getMetadataFromFile(path, existingProbe) |
205 | ||
206 | return metadata.streams.find(s => s.codec_type === 'video') || null | |
207 | } | |
208 | ||
ad5db104 | 209 | function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { |
daf6e480 C |
210 | const configResolutions = type === 'vod' |
211 | ? CONFIG.TRANSCODING.RESOLUTIONS | |
212 | : CONFIG.LIVE.TRANSCODING.RESOLUTIONS | |
213 | ||
214 | const resolutionsEnabled: number[] = [] | |
215 | ||
216 | // Put in the order we want to proceed jobs | |
ad5db104 | 217 | const resolutions: VideoResolution[] = [ |
daf6e480 C |
218 | VideoResolution.H_NOVIDEO, |
219 | VideoResolution.H_480P, | |
220 | VideoResolution.H_360P, | |
221 | VideoResolution.H_720P, | |
222 | VideoResolution.H_240P, | |
8dd754c7 | 223 | VideoResolution.H_144P, |
daf6e480 | 224 | VideoResolution.H_1080P, |
b7085c71 | 225 | VideoResolution.H_1440P, |
daf6e480 C |
226 | VideoResolution.H_4K |
227 | ] | |
228 | ||
229 | for (const resolution of resolutions) { | |
230 | if (configResolutions[resolution + 'p'] === true && videoFileResolution > resolution) { | |
231 | resolutionsEnabled.push(resolution) | |
232 | } | |
233 | } | |
234 | ||
235 | return resolutionsEnabled | |
236 | } | |
237 | ||
238 | async function canDoQuickTranscode (path: string): Promise<boolean> { | |
ffd970fa C |
239 | if (CONFIG.TRANSCODING.PROFILE !== 'default') return false |
240 | ||
daf6e480 C |
241 | const probe = await ffprobePromise(path) |
242 | ||
5a547f69 C |
243 | return await canDoQuickVideoTranscode(path, probe) && |
244 | await canDoQuickAudioTranscode(path, probe) | |
245 | } | |
246 | ||
41fb13c3 | 247 | async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { |
daf6e480 | 248 | const videoStream = await getVideoStreamFromFile(path, probe) |
daf6e480 C |
249 | const fps = await getVideoFileFPS(path, probe) |
250 | const bitRate = await getVideoFileBitrate(path, probe) | |
679c12e6 | 251 | const resolutionData = await getVideoFileResolution(path, probe) |
daf6e480 | 252 | |
33ff70ba C |
253 | // If ffprobe did not manage to guess the bitrate |
254 | if (!bitRate) return false | |
255 | ||
daf6e480 C |
256 | // check video params |
257 | if (videoStream == null) return false | |
258 | if (videoStream['codec_name'] !== 'h264') return false | |
259 | if (videoStream['pix_fmt'] !== 'yuv420p') return false | |
260 | if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false | |
679c12e6 | 261 | if (bitRate > getMaxBitrate({ ...resolutionData, fps })) return false |
daf6e480 | 262 | |
5a547f69 C |
263 | return true |
264 | } | |
265 | ||
41fb13c3 | 266 | async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> { |
5a547f69 C |
267 | const parsedAudio = await getAudioStream(path, probe) |
268 | ||
33ff70ba | 269 | if (!parsedAudio.audioStream) return true |
daf6e480 | 270 | |
33ff70ba | 271 | if (parsedAudio.audioStream['codec_name'] !== 'aac') return false |
daf6e480 | 272 | |
33ff70ba C |
273 | const audioBitrate = parsedAudio.bitrate |
274 | if (!audioBitrate) return false | |
275 | ||
276 | const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate) | |
277 | if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false | |
daf6e480 | 278 | |
d8ba4921 C |
279 | const channelLayout = parsedAudio.audioStream['channel_layout'] |
280 | // Causes playback issues with Chrome | |
281 | if (!channelLayout || channelLayout === 'unknown') return false | |
282 | ||
daf6e480 C |
283 | return true |
284 | } | |
285 | ||
679c12e6 | 286 | function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { |
daf6e480 C |
287 | return VIDEO_TRANSCODING_FPS[type].slice(0) |
288 | .sort((a, b) => fps % a - fps % b)[0] | |
289 | } | |
290 | ||
884d2c39 C |
291 | function computeFPS (fpsArg: number, resolution: VideoResolution) { |
292 | let fps = fpsArg | |
293 | ||
294 | if ( | |
295 | // On small/medium resolutions, limit FPS | |
296 | resolution !== undefined && | |
297 | resolution < VIDEO_TRANSCODING_FPS.KEEP_ORIGIN_FPS_RESOLUTION_MIN && | |
298 | fps > VIDEO_TRANSCODING_FPS.AVERAGE | |
299 | ) { | |
300 | // Get closest standard framerate by modulo: downsampling has to be done to a divisor of the nominal fps value | |
301 | fps = getClosestFramerateStandard(fps, 'STANDARD') | |
302 | } | |
303 | ||
304 | // Hard FPS limits | |
305 | if (fps > VIDEO_TRANSCODING_FPS.MAX) fps = getClosestFramerateStandard(fps, 'HD_STANDARD') | |
f7bb2bb5 C |
306 | |
307 | if (fps < VIDEO_TRANSCODING_FPS.MIN) { | |
308 | throw new Error(`Cannot compute FPS because ${fps} is lower than our minimum value ${VIDEO_TRANSCODING_FPS.MIN}`) | |
309 | } | |
884d2c39 C |
310 | |
311 | return fps | |
312 | } | |
313 | ||
daf6e480 C |
314 | // --------------------------------------------------------------------------- |
315 | ||
316 | export { | |
317 | getVideoStreamCodec, | |
318 | getAudioStreamCodec, | |
319 | getVideoStreamSize, | |
320 | getVideoFileResolution, | |
321 | getMetadataFromFile, | |
322 | getMaxAudioBitrate, | |
5a547f69 | 323 | getVideoStreamFromFile, |
daf6e480 C |
324 | getDurationFromVideoFile, |
325 | getAudioStream, | |
884d2c39 | 326 | computeFPS, |
daf6e480 | 327 | getVideoFileFPS, |
5a547f69 | 328 | ffprobePromise, |
daf6e480 | 329 | getClosestFramerateStandard, |
ad5db104 | 330 | computeLowerResolutionsToTranscode, |
daf6e480 | 331 | getVideoFileBitrate, |
5a547f69 C |
332 | canDoQuickTranscode, |
333 | canDoQuickVideoTranscode, | |
334 | canDoQuickAudioTranscode | |
daf6e480 | 335 | } |