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'
25 getVideoStreamDimensionsInfo,
26 getVideoStreamDuration,
28 } from '@shared/extra-utils'
31 VideoEditionTaskPayload,
33 VideoEditorTaskCutPayload,
34 VideoEditorTaskIntroPayload,
35 VideoEditorTaskOutroPayload,
36 VideoEditorTaskWatermarkPayload,
38 } from '@shared/models'
39 import { logger, loggerTagsFactory } from '../../../helpers/logger'
41 const lTagsBase = loggerTagsFactory('video-edition')
43 async function processVideoEdition (job: Job) {
44 const payload = job.data as VideoEditionPayload
46 logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
48 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
50 // No video, maybe deleted?
52 logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
56 await checkUserQuotaOrThrow(video, payload)
58 const inputFile = video.getMaxQualityFile()
60 const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
61 let tmpInputFilePath: string
62 let outputPath: string
64 for (const task of payload.tasks) {
65 const outputFilename = buildUUID() + inputFile.extname
66 outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
69 inputPath: tmpInputFilePath ?? originalFilePath,
75 if (tmpInputFilePath) await remove(tmpInputFilePath)
77 // For the next iteration
78 tmpInputFilePath = outputPath
84 logger.info('Video edition ended for video %s.', video.uuid)
86 const newFile = await buildNewFile(video, editionResultPath)
88 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
89 await move(editionResultPath, outputPath)
91 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
93 await removeAllFiles(video, newFile)
97 video.state = buildNextVideoState()
98 video.duration = await getVideoStreamDuration(outputPath)
101 await federateVideoIfNeeded(video, false, undefined)
103 if (video.state === VideoState.TO_TRANSCODE) {
104 const user = await UserModel.loadByVideoId(video.id)
106 await addOptimizeOrMergeAudioJob(video, newFile, user, false)
107 } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
108 await addMoveToObjectStorageJob(video, false)
112 // ---------------------------------------------------------------------------
118 // ---------------------------------------------------------------------------
120 type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
127 const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
128 'add-intro': processAddIntroOutro,
129 'add-outro': processAddIntroOutro,
131 'add-watermark': processAddWatermark
134 async function processTask (options: TaskProcessorOptions) {
135 const { video, task } = options
137 logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
139 const processor = taskProcessors[options.task.name]
140 if (!process) throw new Error('Unknown task ' + task.name)
142 return processor(options)
145 function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
146 const { task } = options
148 return addIntroOutro({
149 ...pick(options, [ 'inputPath', 'outputPath' ]),
151 introOutroPath: task.options.file,
152 type: task.name === 'add-intro'
156 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
157 profile: CONFIG.TRANSCODING.PROFILE
161 function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) {
162 const { task } = options
165 ...pick(options, [ 'inputPath', 'outputPath' ]),
167 start: task.options.start,
168 end: task.options.end
172 function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
173 const { task } = options
175 return addWatermark({
176 ...pick(options, [ 'inputPath', 'outputPath' ]),
178 watermarkPath: task.options.file,
180 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
181 profile: CONFIG.TRANSCODING.PROFILE
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,
194 const probe = await ffprobePromise(path)
196 videoFile.fps = await getVideoStreamFPS(path, probe)
197 videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
199 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
204 async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
205 const hls = video.getHLSPlaylist()
208 await video.removeStreamingPlaylistFiles(hls)
212 for (const file of video.VideoFiles) {
213 if (file.id === webTorrentFileException.id) continue
215 await video.removeWebTorrentFileAndTorrent(file)
220 async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) {
221 const user = await UserModel.loadByVideoId(video.id)
223 const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file
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')