]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/lib/job-queue/handlers/video-studio-edition.ts
Add TMP persistent directory
[github/Chocobozzz/PeerTube.git] / server / lib / job-queue / handlers / video-studio-edition.ts
1 import { Job } from 'bullmq'
2 import { move, remove } from 'fs-extra'
3 import { join } from 'path'
4 import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
5 import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
6 import { CONFIG } from '@server/initializers/config'
7 import { VIDEO_FILTERS } from '@server/initializers/constants'
8 import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
9 import { generateWebTorrentVideoFilename } from '@server/lib/paths'
10 import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job'
11 import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
12 import { isAbleToUploadVideo } from '@server/lib/user'
13 import { buildFileMetadata, removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file'
14 import { VideoPathManager } from '@server/lib/video-path-manager'
15 import { approximateIntroOutroAdditionalSize, safeCleanupStudioTMPFiles } from '@server/lib/video-studio'
16 import { UserModel } from '@server/models/user/user'
17 import { VideoModel } from '@server/models/video/video'
18 import { VideoFileModel } from '@server/models/video/video-file'
19 import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
20 import { getLowercaseExtension, pick } from '@shared/core-utils'
21 import { buildUUID, getFileSize } from '@shared/extra-utils'
22 import { FFmpegEdition, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS } from '@shared/ffmpeg'
23 import {
24 VideoStudioEditionPayload,
25 VideoStudioTask,
26 VideoStudioTaskCutPayload,
27 VideoStudioTaskIntroPayload,
28 VideoStudioTaskOutroPayload,
29 VideoStudioTaskPayload,
30 VideoStudioTaskWatermarkPayload
31 } from '@shared/models'
32 import { logger, loggerTagsFactory } from '../../../helpers/logger'
33
34 const lTagsBase = loggerTagsFactory('video-edition')
35
36 async function processVideoStudioEdition (job: Job) {
37 const payload = job.data as VideoStudioEditionPayload
38 const lTags = lTagsBase(payload.videoUUID)
39
40 logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags)
41
42 try {
43 const video = await VideoModel.loadFull(payload.videoUUID)
44
45 // No video, maybe deleted?
46 if (!video) {
47 logger.info('Can\'t process job %d, video does not exist.', job.id, lTags)
48
49 await safeCleanupStudioTMPFiles(payload)
50 return undefined
51 }
52
53 await checkUserQuotaOrThrow(video, payload)
54
55 const inputFile = video.getMaxQualityFile()
56
57 const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
58 let tmpInputFilePath: string
59 let outputPath: string
60
61 for (const task of payload.tasks) {
62 const outputFilename = buildUUID() + inputFile.extname
63 outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
64
65 await processTask({
66 inputPath: tmpInputFilePath ?? originalFilePath,
67 video,
68 outputPath,
69 task,
70 lTags
71 })
72
73 if (tmpInputFilePath) await remove(tmpInputFilePath)
74
75 // For the next iteration
76 tmpInputFilePath = outputPath
77 }
78
79 return outputPath
80 })
81
82 logger.info('Video edition ended for video %s.', video.uuid, lTags)
83
84 const newFile = await buildNewFile(video, editionResultPath)
85
86 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
87 await move(editionResultPath, outputPath)
88
89 await safeCleanupStudioTMPFiles(payload)
90
91 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
92 await removeAllFiles(video, newFile)
93
94 await newFile.save()
95
96 video.duration = await getVideoStreamDuration(outputPath)
97 await video.save()
98
99 await federateVideoIfNeeded(video, false, undefined)
100
101 const user = await UserModel.loadByVideoId(video.id)
102
103 await createOptimizeOrMergeAudioJobs({ video, videoFile: newFile, isNewVideo: false, user, videoFileAlreadyLocked: false })
104 } catch (err) {
105 await safeCleanupStudioTMPFiles(payload)
106
107 throw err
108 }
109 }
110
111 // ---------------------------------------------------------------------------
112
113 export {
114 processVideoStudioEdition
115 }
116
117 // ---------------------------------------------------------------------------
118
119 type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
120 inputPath: string
121 outputPath: string
122 video: MVideo
123 task: T
124 lTags: { tags: string[] }
125 }
126
127 const taskProcessors: { [id in VideoStudioTask['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, lTags } = options
136
137 logger.info('Processing %s task for video %s.', task.name, video.uuid, { task, ...lTags })
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<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
146 const { task, lTags } = options
147
148 logger.debug('Will add intro/outro to the video.', { options, ...lTags })
149
150 return buildFFmpegEdition().addIntroOutro({
151 ...pick(options, [ 'inputPath', 'outputPath' ]),
152
153 introOutroPath: task.options.file,
154 type: task.name === 'add-intro'
155 ? 'intro'
156 : 'outro'
157 })
158 }
159
160 function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
161 const { task, lTags } = options
162
163 logger.debug('Will cut the video.', { options, ...lTags })
164
165 return buildFFmpegEdition().cutVideo({
166 ...pick(options, [ 'inputPath', 'outputPath' ]),
167
168 start: task.options.start,
169 end: task.options.end
170 })
171 }
172
173 function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
174 const { task, lTags } = options
175
176 logger.debug('Will add watermark to the video.', { options, ...lTags })
177
178 return buildFFmpegEdition().addWatermark({
179 ...pick(options, [ 'inputPath', 'outputPath' ]),
180
181 watermarkPath: task.options.file,
182
183 videoFilters: {
184 watermarkSizeRatio: VIDEO_FILTERS.WATERMARK.SIZE_RATIO,
185 horitonzalMarginRatio: VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO,
186 verticalMarginRatio: VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO
187 }
188 })
189 }
190
191 // ---------------------------------------------------------------------------
192
193 async function buildNewFile (video: MVideoId, path: string) {
194 const videoFile = new VideoFileModel({
195 extname: getLowercaseExtension(path),
196 size: await getFileSize(path),
197 metadata: await buildFileMetadata(path),
198 videoStreamingPlaylistId: null,
199 videoId: video.id
200 })
201
202 const probe = await ffprobePromise(path)
203
204 videoFile.fps = await getVideoStreamFPS(path, probe)
205 videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
206
207 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
208
209 return videoFile
210 }
211
212 async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
213 await removeHLSPlaylist(video)
214
215 for (const file of video.VideoFiles) {
216 if (file.id === webTorrentFileException.id) continue
217
218 await removeWebTorrentFile(video, file.id)
219 }
220 }
221
222 async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoStudioEditionPayload) {
223 const user = await UserModel.loadByVideoId(video.id)
224
225 const filePathFinder = (i: number) => (payload.tasks[i] as VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload).options.file
226
227 const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
228 if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
229 throw new Error('Quota exceeded for this user to edit the video')
230 }
231 }
232
233 function buildFFmpegEdition () {
234 return new FFmpegEdition(getFFmpegCommandWrapperOptions('vod', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()))
235 }