]>
Commit | Line | Data |
---|---|---|
1 | import { Job } from 'bullmq' | |
2 | import { copyFile, ensureDir, move, remove, stat } from 'fs-extra' | |
3 | import { basename, extname as extnameUtil, join } from 'path' | |
4 | import { toEven } from '@server/helpers/core-utils' | |
5 | import { retryTransactionWrapper } from '@server/helpers/database-utils' | |
6 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | |
7 | import { sequelizeTypescript } from '@server/initializers/database' | |
8 | import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | |
9 | import { VideoResolution, VideoStorage } from '../../../shared/models/videos' | |
10 | import { | |
11 | buildFileMetadata, | |
12 | canDoQuickTranscode, | |
13 | computeResolutionsToTranscode, | |
14 | getVideoStreamDuration, | |
15 | getVideoStreamFPS, | |
16 | transcodeVOD, | |
17 | TranscodeVODOptions, | |
18 | TranscodeVODOptionsType | |
19 | } from '../../helpers/ffmpeg' | |
20 | import { CONFIG } from '../../initializers/config' | |
21 | import { VideoFileModel } from '../../models/video/video-file' | |
22 | import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist' | |
23 | import { updatePlaylistAfterFileChange } from '../hls' | |
24 | import { generateHLSVideoFilename, generateWebTorrentVideoFilename, getHlsResolutionPlaylistFilename } from '../paths' | |
25 | import { VideoPathManager } from '../video-path-manager' | |
26 | import { VideoTranscodingProfilesManager } from './default-transcoding-profiles' | |
27 | ||
28 | /** | |
29 | * | |
30 | * Functions that run transcoding functions, update the database, cleanup files, create torrent files... | |
31 | * Mainly called by the job queue | |
32 | * | |
33 | */ | |
34 | ||
35 | // Optimize the original video file and replace it. The resolution is not changed. | |
36 | function optimizeOriginalVideofile (options: { | |
37 | video: MVideoFullLight | |
38 | inputVideoFile: MVideoFile | |
39 | job: Job | |
40 | }) { | |
41 | const { video, inputVideoFile, job } = options | |
42 | ||
43 | const transcodeDirectory = CONFIG.STORAGE.TMP_DIR | |
44 | const newExtname = '.mp4' | |
45 | ||
46 | return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => { | |
47 | const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) | |
48 | ||
49 | const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath) | |
50 | ? 'quick-transcode' | |
51 | : 'video' | |
52 | ||
53 | const resolution = buildOriginalFileResolution(inputVideoFile.resolution) | |
54 | ||
55 | const transcodeOptions: TranscodeVODOptions = { | |
56 | type: transcodeType, | |
57 | ||
58 | inputPath: videoInputPath, | |
59 | outputPath: videoTranscodedPath, | |
60 | ||
61 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
62 | profile: CONFIG.TRANSCODING.PROFILE, | |
63 | ||
64 | resolution, | |
65 | ||
66 | job | |
67 | } | |
68 | ||
69 | // Could be very long! | |
70 | await transcodeVOD(transcodeOptions) | |
71 | ||
72 | // Important to do this before getVideoFilename() to take in account the new filename | |
73 | inputVideoFile.resolution = resolution | |
74 | inputVideoFile.extname = newExtname | |
75 | inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname) | |
76 | inputVideoFile.storage = VideoStorage.FILE_SYSTEM | |
77 | ||
78 | const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile) | |
79 | ||
80 | const { videoFile } = await onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) | |
81 | await remove(videoInputPath) | |
82 | ||
83 | return { transcodeType, videoFile } | |
84 | }) | |
85 | } | |
86 | ||
87 | // Transcode the original video file to a lower resolution compatible with WebTorrent | |
88 | function transcodeNewWebTorrentResolution (options: { | |
89 | video: MVideoFullLight | |
90 | resolution: VideoResolution | |
91 | job: Job | |
92 | }) { | |
93 | const { video, resolution, job } = options | |
94 | ||
95 | const transcodeDirectory = CONFIG.STORAGE.TMP_DIR | |
96 | const newExtname = '.mp4' | |
97 | ||
98 | return VideoPathManager.Instance.makeAvailableVideoFile(video.getMaxQualityFile().withVideoOrPlaylist(video), async videoInputPath => { | |
99 | const newVideoFile = new VideoFileModel({ | |
100 | resolution, | |
101 | extname: newExtname, | |
102 | filename: generateWebTorrentVideoFilename(resolution, newExtname), | |
103 | size: 0, | |
104 | videoId: video.id | |
105 | }) | |
106 | ||
107 | const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile) | |
108 | const videoTranscodedPath = join(transcodeDirectory, newVideoFile.filename) | |
109 | ||
110 | const transcodeOptions = resolution === VideoResolution.H_NOVIDEO | |
111 | ? { | |
112 | type: 'only-audio' as 'only-audio', | |
113 | ||
114 | inputPath: videoInputPath, | |
115 | outputPath: videoTranscodedPath, | |
116 | ||
117 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
118 | profile: CONFIG.TRANSCODING.PROFILE, | |
119 | ||
120 | resolution, | |
121 | ||
122 | job | |
123 | } | |
124 | : { | |
125 | type: 'video' as 'video', | |
126 | inputPath: videoInputPath, | |
127 | outputPath: videoTranscodedPath, | |
128 | ||
129 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
130 | profile: CONFIG.TRANSCODING.PROFILE, | |
131 | ||
132 | resolution, | |
133 | ||
134 | job | |
135 | } | |
136 | ||
137 | await transcodeVOD(transcodeOptions) | |
138 | ||
139 | return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) | |
140 | }) | |
141 | } | |
142 | ||
143 | // Merge an image with an audio file to create a video | |
144 | function mergeAudioVideofile (options: { | |
145 | video: MVideoFullLight | |
146 | resolution: VideoResolution | |
147 | job: Job | |
148 | }) { | |
149 | const { video, resolution, job } = options | |
150 | ||
151 | const transcodeDirectory = CONFIG.STORAGE.TMP_DIR | |
152 | const newExtname = '.mp4' | |
153 | ||
154 | const inputVideoFile = video.getMinQualityFile() | |
155 | ||
156 | return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async audioInputPath => { | |
157 | const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) | |
158 | ||
159 | // If the user updates the video preview during transcoding | |
160 | const previewPath = video.getPreview().getPath() | |
161 | const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath)) | |
162 | await copyFile(previewPath, tmpPreviewPath) | |
163 | ||
164 | const transcodeOptions = { | |
165 | type: 'merge-audio' as 'merge-audio', | |
166 | ||
167 | inputPath: tmpPreviewPath, | |
168 | outputPath: videoTranscodedPath, | |
169 | ||
170 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
171 | profile: CONFIG.TRANSCODING.PROFILE, | |
172 | ||
173 | audioPath: audioInputPath, | |
174 | resolution, | |
175 | ||
176 | job | |
177 | } | |
178 | ||
179 | try { | |
180 | await transcodeVOD(transcodeOptions) | |
181 | ||
182 | await remove(audioInputPath) | |
183 | await remove(tmpPreviewPath) | |
184 | } catch (err) { | |
185 | await remove(tmpPreviewPath) | |
186 | throw err | |
187 | } | |
188 | ||
189 | // Important to do this before getVideoFilename() to take in account the new file extension | |
190 | inputVideoFile.extname = newExtname | |
191 | inputVideoFile.resolution = resolution | |
192 | inputVideoFile.filename = generateWebTorrentVideoFilename(inputVideoFile.resolution, newExtname) | |
193 | ||
194 | const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile) | |
195 | // ffmpeg generated a new video file, so update the video duration | |
196 | // See https://trac.ffmpeg.org/ticket/5456 | |
197 | video.duration = await getVideoStreamDuration(videoTranscodedPath) | |
198 | await video.save() | |
199 | ||
200 | return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) | |
201 | }) | |
202 | } | |
203 | ||
204 | // Concat TS segments from a live video to a fragmented mp4 HLS playlist | |
205 | async function generateHlsPlaylistResolutionFromTS (options: { | |
206 | video: MVideo | |
207 | concatenatedTsFilePath: string | |
208 | resolution: VideoResolution | |
209 | isAAC: boolean | |
210 | }) { | |
211 | return generateHlsPlaylistCommon({ | |
212 | video: options.video, | |
213 | resolution: options.resolution, | |
214 | inputPath: options.concatenatedTsFilePath, | |
215 | type: 'hls-from-ts' as 'hls-from-ts', | |
216 | isAAC: options.isAAC | |
217 | }) | |
218 | } | |
219 | ||
220 | // Generate an HLS playlist from an input file, and update the master playlist | |
221 | function generateHlsPlaylistResolution (options: { | |
222 | video: MVideo | |
223 | videoInputPath: string | |
224 | resolution: VideoResolution | |
225 | copyCodecs: boolean | |
226 | job?: Job | |
227 | }) { | |
228 | return generateHlsPlaylistCommon({ | |
229 | video: options.video, | |
230 | resolution: options.resolution, | |
231 | copyCodecs: options.copyCodecs, | |
232 | inputPath: options.videoInputPath, | |
233 | type: 'hls' as 'hls', | |
234 | job: options.job | |
235 | }) | |
236 | } | |
237 | ||
238 | // --------------------------------------------------------------------------- | |
239 | ||
240 | export { | |
241 | generateHlsPlaylistResolution, | |
242 | generateHlsPlaylistResolutionFromTS, | |
243 | optimizeOriginalVideofile, | |
244 | transcodeNewWebTorrentResolution, | |
245 | mergeAudioVideofile | |
246 | } | |
247 | ||
248 | // --------------------------------------------------------------------------- | |
249 | ||
250 | async function onWebTorrentVideoFileTranscoding ( | |
251 | video: MVideoFullLight, | |
252 | videoFile: MVideoFile, | |
253 | transcodingPath: string, | |
254 | outputPath: string | |
255 | ) { | |
256 | const stats = await stat(transcodingPath) | |
257 | const fps = await getVideoStreamFPS(transcodingPath) | |
258 | const metadata = await buildFileMetadata(transcodingPath) | |
259 | ||
260 | await move(transcodingPath, outputPath, { overwrite: true }) | |
261 | ||
262 | videoFile.size = stats.size | |
263 | videoFile.fps = fps | |
264 | videoFile.metadata = metadata | |
265 | ||
266 | await createTorrentAndSetInfoHash(video, videoFile) | |
267 | ||
268 | const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) | |
269 | if (oldFile) await video.removeWebTorrentFile(oldFile) | |
270 | ||
271 | await VideoFileModel.customUpsert(videoFile, 'video', undefined) | |
272 | video.VideoFiles = await video.$get('VideoFiles') | |
273 | ||
274 | return { video, videoFile } | |
275 | } | |
276 | ||
277 | async function generateHlsPlaylistCommon (options: { | |
278 | type: 'hls' | 'hls-from-ts' | |
279 | video: MVideo | |
280 | inputPath: string | |
281 | resolution: VideoResolution | |
282 | copyCodecs?: boolean | |
283 | isAAC?: boolean | |
284 | ||
285 | job?: Job | |
286 | }) { | |
287 | const { type, video, inputPath, resolution, copyCodecs, isAAC, job } = options | |
288 | const transcodeDirectory = CONFIG.STORAGE.TMP_DIR | |
289 | ||
290 | const videoTranscodedBasePath = join(transcodeDirectory, type) | |
291 | await ensureDir(videoTranscodedBasePath) | |
292 | ||
293 | const videoFilename = generateHLSVideoFilename(resolution) | |
294 | const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename) | |
295 | const resolutionPlaylistFileTranscodePath = join(videoTranscodedBasePath, resolutionPlaylistFilename) | |
296 | ||
297 | const transcodeOptions = { | |
298 | type, | |
299 | ||
300 | inputPath, | |
301 | outputPath: resolutionPlaylistFileTranscodePath, | |
302 | ||
303 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
304 | profile: CONFIG.TRANSCODING.PROFILE, | |
305 | ||
306 | resolution, | |
307 | copyCodecs, | |
308 | ||
309 | isAAC, | |
310 | ||
311 | hlsPlaylist: { | |
312 | videoFilename | |
313 | }, | |
314 | ||
315 | job | |
316 | } | |
317 | ||
318 | await transcodeVOD(transcodeOptions) | |
319 | ||
320 | // Create or update the playlist | |
321 | const playlist = await retryTransactionWrapper(() => { | |
322 | return sequelizeTypescript.transaction(async transaction => { | |
323 | return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction) | |
324 | }) | |
325 | }) | |
326 | ||
327 | const newVideoFile = new VideoFileModel({ | |
328 | resolution, | |
329 | extname: extnameUtil(videoFilename), | |
330 | size: 0, | |
331 | filename: videoFilename, | |
332 | fps: -1, | |
333 | videoStreamingPlaylistId: playlist.id | |
334 | }) | |
335 | ||
336 | const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, newVideoFile) | |
337 | await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video)) | |
338 | ||
339 | // Move playlist file | |
340 | const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, resolutionPlaylistFilename) | |
341 | await move(resolutionPlaylistFileTranscodePath, resolutionPlaylistPath, { overwrite: true }) | |
342 | // Move video file | |
343 | await move(join(videoTranscodedBasePath, videoFilename), videoFilePath, { overwrite: true }) | |
344 | ||
345 | // Update video duration if it was not set (in case of a live for example) | |
346 | if (!video.duration) { | |
347 | video.duration = await getVideoStreamDuration(videoFilePath) | |
348 | await video.save() | |
349 | } | |
350 | ||
351 | const stats = await stat(videoFilePath) | |
352 | ||
353 | newVideoFile.size = stats.size | |
354 | newVideoFile.fps = await getVideoStreamFPS(videoFilePath) | |
355 | newVideoFile.metadata = await buildFileMetadata(videoFilePath) | |
356 | ||
357 | await createTorrentAndSetInfoHash(playlist, newVideoFile) | |
358 | ||
359 | const oldFile = await VideoFileModel.loadHLSFile({ playlistId: playlist.id, fps: newVideoFile.fps, resolution: newVideoFile.resolution }) | |
360 | if (oldFile) { | |
361 | await video.removeStreamingPlaylistVideoFile(playlist, oldFile) | |
362 | await oldFile.destroy() | |
363 | } | |
364 | ||
365 | const savedVideoFile = await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) | |
366 | ||
367 | await updatePlaylistAfterFileChange(video, playlist) | |
368 | ||
369 | return { resolutionPlaylistPath, videoFile: savedVideoFile } | |
370 | } | |
371 | ||
372 | function buildOriginalFileResolution (inputResolution: number) { | |
373 | if (CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION === true) return toEven(inputResolution) | |
374 | ||
375 | const resolutions = computeResolutionsToTranscode({ input: inputResolution, type: 'vod', includeInput: false, strictLower: false }) | |
376 | if (resolutions.length === 0) return toEven(inputResolution) | |
377 | ||
378 | return Math.max(...resolutions) | |
379 | } |