]>
Commit | Line | Data |
---|---|---|
c729caf6 C |
1 | import { Job } from 'bull' |
2 | import { move, remove } from 'fs-extra' | |
3 | import { join } from 'path' | |
4 | import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg' | |
5 | import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent' | |
6 | import { CONFIG } from '@server/initializers/config' | |
7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | |
8 | import { generateWebTorrentVideoFilename } from '@server/lib/paths' | |
9 | import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles' | |
10 | import { isAbleToUploadVideo } from '@server/lib/user' | |
11 | import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video' | |
12 | import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor' | |
13 | import { VideoPathManager } from '@server/lib/video-path-manager' | |
14 | import { buildNextVideoState } from '@server/lib/video-state' | |
15 | import { UserModel } from '@server/models/user/user' | |
16 | import { VideoModel } from '@server/models/video/video' | |
17 | import { VideoFileModel } from '@server/models/video/video-file' | |
18 | import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models' | |
19 | import { getLowercaseExtension, pick } from '@shared/core-utils' | |
20 | import { | |
21 | buildFileMetadata, | |
22 | buildUUID, | |
23 | ffprobePromise, | |
24 | getFileSize, | |
25 | getVideoStreamDimensionsInfo, | |
26 | getVideoStreamDuration, | |
27 | getVideoStreamFPS | |
28 | } from '@shared/extra-utils' | |
29 | import { | |
30 | VideoEditionPayload, | |
31 | VideoEditionTaskPayload, | |
32 | VideoEditorTask, | |
33 | VideoEditorTaskCutPayload, | |
34 | VideoEditorTaskIntroPayload, | |
35 | VideoEditorTaskOutroPayload, | |
36 | VideoEditorTaskWatermarkPayload, | |
37 | VideoState | |
38 | } from '@shared/models' | |
39 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | |
40 | ||
41 | const lTagsBase = loggerTagsFactory('video-edition') | |
42 | ||
43 | async function processVideoEdition (job: Job) { | |
44 | const payload = job.data as VideoEditionPayload | |
45 | ||
46 | logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id) | |
47 | ||
48 | const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID) | |
49 | ||
50 | // No video, maybe deleted? | |
51 | if (!video) { | |
52 | logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID)) | |
53 | return undefined | |
54 | } | |
55 | ||
56 | await checkUserQuotaOrThrow(video, payload) | |
57 | ||
58 | const inputFile = video.getMaxQualityFile() | |
59 | ||
60 | const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => { | |
61 | let tmpInputFilePath: string | |
62 | let outputPath: string | |
63 | ||
64 | for (const task of payload.tasks) { | |
65 | const outputFilename = buildUUID() + inputFile.extname | |
66 | outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename) | |
67 | ||
68 | await processTask({ | |
69 | inputPath: tmpInputFilePath ?? originalFilePath, | |
70 | video, | |
71 | outputPath, | |
72 | task | |
73 | }) | |
74 | ||
75 | if (tmpInputFilePath) await remove(tmpInputFilePath) | |
76 | ||
77 | // For the next iteration | |
78 | tmpInputFilePath = outputPath | |
79 | } | |
80 | ||
81 | return outputPath | |
82 | }) | |
83 | ||
84 | logger.info('Video edition ended for video %s.', video.uuid) | |
85 | ||
86 | const newFile = await buildNewFile(video, editionResultPath) | |
87 | ||
88 | const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile) | |
89 | await move(editionResultPath, outputPath) | |
90 | ||
91 | await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath) | |
92 | ||
93 | await removeAllFiles(video, newFile) | |
94 | ||
95 | await newFile.save() | |
96 | ||
97 | video.state = buildNextVideoState() | |
98 | video.duration = await getVideoStreamDuration(outputPath) | |
99 | await video.save() | |
100 | ||
101 | await federateVideoIfNeeded(video, false, undefined) | |
102 | ||
103 | if (video.state === VideoState.TO_TRANSCODE) { | |
104 | const user = await UserModel.loadByVideoId(video.id) | |
105 | ||
106 | await addOptimizeOrMergeAudioJob(video, newFile, user, false) | |
107 | } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { | |
108 | await addMoveToObjectStorageJob(video, false) | |
109 | } | |
110 | } | |
111 | ||
112 | // --------------------------------------------------------------------------- | |
113 | ||
114 | export { | |
115 | processVideoEdition | |
116 | } | |
117 | ||
118 | // --------------------------------------------------------------------------- | |
119 | ||
120 | type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = { | |
121 | inputPath: string | |
122 | outputPath: string | |
123 | video: MVideo | |
124 | task: T | |
125 | } | |
126 | ||
127 | const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = { | |
128 | 'add-intro': processAddIntroOutro, | |
129 | 'add-outro': processAddIntroOutro, | |
130 | 'cut': processCut, | |
131 | 'add-watermark': processAddWatermark | |
132 | } | |
133 | ||
134 | async function processTask (options: TaskProcessorOptions) { | |
135 | const { video, task } = options | |
136 | ||
137 | logger.info('Processing %s task for video %s.', task.name, video.uuid, { task }) | |
138 | ||
139 | const processor = taskProcessors[options.task.name] | |
140 | if (!process) throw new Error('Unknown task ' + task.name) | |
141 | ||
142 | return processor(options) | |
143 | } | |
144 | ||
145 | function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) { | |
146 | const { task } = options | |
147 | ||
148 | return addIntroOutro({ | |
149 | ...pick(options, [ 'inputPath', 'outputPath' ]), | |
150 | ||
151 | introOutroPath: task.options.file, | |
152 | type: task.name === 'add-intro' | |
153 | ? 'intro' | |
154 | : 'outro', | |
155 | ||
156 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
157 | profile: CONFIG.TRANSCODING.PROFILE | |
158 | }) | |
159 | } | |
160 | ||
161 | function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) { | |
162 | const { task } = options | |
163 | ||
164 | return cutVideo({ | |
165 | ...pick(options, [ 'inputPath', 'outputPath' ]), | |
166 | ||
167 | start: task.options.start, | |
168 | end: task.options.end | |
169 | }) | |
170 | } | |
171 | ||
172 | function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) { | |
173 | const { task } = options | |
174 | ||
175 | return addWatermark({ | |
176 | ...pick(options, [ 'inputPath', 'outputPath' ]), | |
177 | ||
178 | watermarkPath: task.options.file, | |
179 | ||
180 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | |
181 | profile: CONFIG.TRANSCODING.PROFILE | |
182 | }) | |
183 | } | |
184 | ||
185 | async function buildNewFile (video: MVideoId, path: string) { | |
186 | const videoFile = new VideoFileModel({ | |
187 | extname: getLowercaseExtension(path), | |
188 | size: await getFileSize(path), | |
189 | metadata: await buildFileMetadata(path), | |
190 | videoStreamingPlaylistId: null, | |
191 | videoId: video.id | |
192 | }) | |
193 | ||
194 | const probe = await ffprobePromise(path) | |
195 | ||
196 | videoFile.fps = await getVideoStreamFPS(path, probe) | |
197 | videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution | |
198 | ||
199 | videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname) | |
200 | ||
201 | return videoFile | |
202 | } | |
203 | ||
204 | async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) { | |
205 | const hls = video.getHLSPlaylist() | |
206 | ||
207 | if (hls) { | |
208 | await video.removeStreamingPlaylistFiles(hls) | |
209 | await hls.destroy() | |
210 | } | |
211 | ||
212 | for (const file of video.VideoFiles) { | |
213 | if (file.id === webTorrentFileException.id) continue | |
214 | ||
215 | await video.removeWebTorrentFileAndTorrent(file) | |
216 | await file.destroy() | |
217 | } | |
218 | } | |
219 | ||
220 | async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) { | |
221 | const user = await UserModel.loadByVideoId(video.id) | |
222 | ||
223 | const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file | |
224 | ||
225 | const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder) | |
226 | if (await isAbleToUploadVideo(user.id, additionalBytes) === false) { | |
227 | throw new Error('Quota exceeded for this user to edit the video') | |
228 | } | |
229 | } |