diff options
Diffstat (limited to 'server/lib/job-queue/handlers/video-studio-edition.ts')
-rw-r--r-- | server/lib/job-queue/handlers/video-studio-edition.ts | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/server/lib/job-queue/handlers/video-studio-edition.ts b/server/lib/job-queue/handlers/video-studio-edition.ts new file mode 100644 index 000000000..cf3064a7a --- /dev/null +++ b/server/lib/job-queue/handlers/video-studio-edition.ts | |||
@@ -0,0 +1,224 @@ | |||
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 { addOptimizeOrMergeAudioJob } from '@server/lib/video' | ||
12 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
13 | import { approximateIntroOutroAdditionalSize } from '@server/lib/video-studio' | ||
14 | import { UserModel } from '@server/models/user/user' | ||
15 | import { VideoModel } from '@server/models/video/video' | ||
16 | import { VideoFileModel } from '@server/models/video/video-file' | ||
17 | import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models' | ||
18 | import { getLowercaseExtension, pick } from '@shared/core-utils' | ||
19 | import { | ||
20 | buildFileMetadata, | ||
21 | buildUUID, | ||
22 | ffprobePromise, | ||
23 | getFileSize, | ||
24 | getVideoStreamDimensionsInfo, | ||
25 | getVideoStreamDuration, | ||
26 | getVideoStreamFPS | ||
27 | } from '@shared/extra-utils' | ||
28 | import { | ||
29 | VideoStudioEditionPayload, | ||
30 | VideoStudioTaskPayload, | ||
31 | VideoStudioTaskCutPayload, | ||
32 | VideoStudioTaskIntroPayload, | ||
33 | VideoStudioTaskOutroPayload, | ||
34 | VideoStudioTaskWatermarkPayload, | ||
35 | VideoStudioTask | ||
36 | } from '@shared/models' | ||
37 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
38 | |||
39 | const lTagsBase = loggerTagsFactory('video-edition') | ||
40 | |||
41 | async function processVideoStudioEdition (job: Job) { | ||
42 | const payload = job.data as VideoStudioEditionPayload | ||
43 | const lTags = lTagsBase(payload.videoUUID) | ||
44 | |||
45 | logger.info('Process video studio edition of %s in job %d.', payload.videoUUID, job.id, lTags) | ||
46 | |||
47 | const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID) | ||
48 | |||
49 | // No video, maybe deleted? | ||
50 | if (!video) { | ||
51 | logger.info('Can\'t process job %d, video does not exist.', job.id, lTags) | ||
52 | return undefined | ||
53 | } | ||
54 | |||
55 | await checkUserQuotaOrThrow(video, payload) | ||
56 | |||
57 | const inputFile = video.getMaxQualityFile() | ||
58 | |||
59 | const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => { | ||
60 | let tmpInputFilePath: string | ||
61 | let outputPath: string | ||
62 | |||
63 | for (const task of payload.tasks) { | ||
64 | const outputFilename = buildUUID() + inputFile.extname | ||
65 | outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename) | ||
66 | |||
67 | await processTask({ | ||
68 | inputPath: tmpInputFilePath ?? originalFilePath, | ||
69 | video, | ||
70 | outputPath, | ||
71 | task, | ||
72 | lTags | ||
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, lTags) | ||
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.duration = await getVideoStreamDuration(outputPath) | ||
98 | await video.save() | ||
99 | |||
100 | await federateVideoIfNeeded(video, false, undefined) | ||
101 | |||
102 | const user = await UserModel.loadByVideoId(video.id) | ||
103 | await addOptimizeOrMergeAudioJob({ video, videoFile: newFile, user, isNewVideo: false }) | ||
104 | } | ||
105 | |||
106 | // --------------------------------------------------------------------------- | ||
107 | |||
108 | export { | ||
109 | processVideoStudioEdition | ||
110 | } | ||
111 | |||
112 | // --------------------------------------------------------------------------- | ||
113 | |||
114 | type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = { | ||
115 | inputPath: string | ||
116 | outputPath: string | ||
117 | video: MVideo | ||
118 | task: T | ||
119 | lTags: { tags: string[] } | ||
120 | } | ||
121 | |||
122 | const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = { | ||
123 | 'add-intro': processAddIntroOutro, | ||
124 | 'add-outro': processAddIntroOutro, | ||
125 | 'cut': processCut, | ||
126 | 'add-watermark': processAddWatermark | ||
127 | } | ||
128 | |||
129 | async function processTask (options: TaskProcessorOptions) { | ||
130 | const { video, task } = options | ||
131 | |||
132 | logger.info('Processing %s task for video %s.', task.name, video.uuid, { task, ...options.lTags }) | ||
133 | |||
134 | const processor = taskProcessors[options.task.name] | ||
135 | if (!process) throw new Error('Unknown task ' + task.name) | ||
136 | |||
137 | return processor(options) | ||
138 | } | ||
139 | |||
140 | function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) { | ||
141 | const { task } = options | ||
142 | |||
143 | return addIntroOutro({ | ||
144 | ...pick(options, [ 'inputPath', 'outputPath' ]), | ||
145 | |||
146 | introOutroPath: task.options.file, | ||
147 | type: task.name === 'add-intro' | ||
148 | ? 'intro' | ||
149 | : 'outro', | ||
150 | |||
151 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | ||
152 | profile: CONFIG.TRANSCODING.PROFILE | ||
153 | }) | ||
154 | } | ||
155 | |||
156 | function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) { | ||
157 | const { task } = options | ||
158 | |||
159 | return cutVideo({ | ||
160 | ...pick(options, [ 'inputPath', 'outputPath' ]), | ||
161 | |||
162 | start: task.options.start, | ||
163 | end: task.options.end | ||
164 | }) | ||
165 | } | ||
166 | |||
167 | function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) { | ||
168 | const { task } = options | ||
169 | |||
170 | return addWatermark({ | ||
171 | ...pick(options, [ 'inputPath', 'outputPath' ]), | ||
172 | |||
173 | watermarkPath: task.options.file, | ||
174 | |||
175 | availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), | ||
176 | profile: CONFIG.TRANSCODING.PROFILE | ||
177 | }) | ||
178 | } | ||
179 | |||
180 | async function buildNewFile (video: MVideoId, path: string) { | ||
181 | const videoFile = new VideoFileModel({ | ||
182 | extname: getLowercaseExtension(path), | ||
183 | size: await getFileSize(path), | ||
184 | metadata: await buildFileMetadata(path), | ||
185 | videoStreamingPlaylistId: null, | ||
186 | videoId: video.id | ||
187 | }) | ||
188 | |||
189 | const probe = await ffprobePromise(path) | ||
190 | |||
191 | videoFile.fps = await getVideoStreamFPS(path, probe) | ||
192 | videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution | ||
193 | |||
194 | videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname) | ||
195 | |||
196 | return videoFile | ||
197 | } | ||
198 | |||
199 | async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) { | ||
200 | const hls = video.getHLSPlaylist() | ||
201 | |||
202 | if (hls) { | ||
203 | await video.removeStreamingPlaylistFiles(hls) | ||
204 | await hls.destroy() | ||
205 | } | ||
206 | |||
207 | for (const file of video.VideoFiles) { | ||
208 | if (file.id === webTorrentFileException.id) continue | ||
209 | |||
210 | await video.removeWebTorrentFileAndTorrent(file) | ||
211 | await file.destroy() | ||
212 | } | ||
213 | } | ||
214 | |||
215 | async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoStudioEditionPayload) { | ||
216 | const user = await UserModel.loadByVideoId(video.id) | ||
217 | |||
218 | const filePathFinder = (i: number) => (payload.tasks[i] as VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload).options.file | ||
219 | |||
220 | const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder) | ||
221 | if (await isAbleToUploadVideo(user.id, additionalBytes) === false) { | ||
222 | throw new Error('Quota exceeded for this user to edit the video') | ||
223 | } | ||
224 | } | ||