2022-08-08 10:42:08 +02:00
|
|
|
import { Job } from 'bullmq'
|
2022-02-11 10:51:33 +01:00
|
|
|
import { move, remove } from 'fs-extra'
|
|
|
|
import { join } from 'path'
|
|
|
|
import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg'
|
|
|
|
import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
|
|
|
|
import { CONFIG } from '@server/initializers/config'
|
|
|
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
|
|
|
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
|
|
|
|
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
|
|
|
|
import { isAbleToUploadVideo } from '@server/lib/user'
|
2022-08-08 15:48:17 +02:00
|
|
|
import { buildOptimizeOrMergeAudioJob } from '@server/lib/video'
|
2022-07-29 14:50:41 +02:00
|
|
|
import { removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file'
|
2022-02-11 10:51:33 +01:00
|
|
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
2022-03-22 16:58:49 +01:00
|
|
|
import { approximateIntroOutroAdditionalSize } from '@server/lib/video-studio'
|
2022-02-11 10:51:33 +01:00
|
|
|
import { UserModel } from '@server/models/user/user'
|
|
|
|
import { VideoModel } from '@server/models/video/video'
|
|
|
|
import { VideoFileModel } from '@server/models/video/video-file'
|
|
|
|
import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
|
|
|
|
import { getLowercaseExtension, pick } from '@shared/core-utils'
|
|
|
|
import {
|
|
|
|
buildFileMetadata,
|
|
|
|
buildUUID,
|
|
|
|
ffprobePromise,
|
|
|
|
getFileSize,
|
|
|
|
getVideoStreamDimensionsInfo,
|
|
|
|
getVideoStreamDuration,
|
|
|
|
getVideoStreamFPS
|
|
|
|
} from '@shared/extra-utils'
|
|
|
|
import {
|
2022-03-22 16:58:49 +01:00
|
|
|
VideoStudioEditionPayload,
|
2022-07-29 14:50:41 +02:00
|
|
|
VideoStudioTask,
|
2022-03-22 16:58:49 +01:00
|
|
|
VideoStudioTaskCutPayload,
|
|
|
|
VideoStudioTaskIntroPayload,
|
|
|
|
VideoStudioTaskOutroPayload,
|
2022-07-29 14:50:41 +02:00
|
|
|
VideoStudioTaskPayload,
|
|
|
|
VideoStudioTaskWatermarkPayload
|
2022-02-11 10:51:33 +01:00
|
|
|
} from '@shared/models'
|
|
|
|
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
2022-08-08 15:48:17 +02:00
|
|
|
import { JobQueue } from '../job-queue'
|
2022-02-11 10:51:33 +01:00
|
|
|
|
|
|
|
const lTagsBase = loggerTagsFactory('video-edition')
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
async function processVideoStudioEdition (job: Job) {
|
|
|
|
const payload = job.data as VideoStudioEditionPayload
|
2022-03-22 14:35:04 +01:00
|
|
|
const lTags = lTagsBase(payload.videoUUID)
|
2022-02-11 10:51:33 +01:00
|
|
|
|
2022-08-08 15:48:17 +02:00
|
|
|
logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags)
|
2022-02-11 10:51:33 +01:00
|
|
|
|
2022-06-28 14:57:51 +02:00
|
|
|
const video = await VideoModel.loadFull(payload.videoUUID)
|
2022-02-11 10:51:33 +01:00
|
|
|
|
|
|
|
// No video, maybe deleted?
|
|
|
|
if (!video) {
|
2022-03-22 14:35:04 +01:00
|
|
|
logger.info('Can\'t process job %d, video does not exist.', job.id, lTags)
|
2022-02-11 10:51:33 +01:00
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
|
|
|
|
await checkUserQuotaOrThrow(video, payload)
|
|
|
|
|
|
|
|
const inputFile = video.getMaxQualityFile()
|
|
|
|
|
|
|
|
const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
|
|
|
|
let tmpInputFilePath: string
|
|
|
|
let outputPath: string
|
|
|
|
|
|
|
|
for (const task of payload.tasks) {
|
|
|
|
const outputFilename = buildUUID() + inputFile.extname
|
|
|
|
outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
|
|
|
|
|
|
|
|
await processTask({
|
|
|
|
inputPath: tmpInputFilePath ?? originalFilePath,
|
|
|
|
video,
|
|
|
|
outputPath,
|
2022-03-22 14:35:04 +01:00
|
|
|
task,
|
|
|
|
lTags
|
2022-02-11 10:51:33 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
if (tmpInputFilePath) await remove(tmpInputFilePath)
|
|
|
|
|
|
|
|
// For the next iteration
|
|
|
|
tmpInputFilePath = outputPath
|
|
|
|
}
|
|
|
|
|
|
|
|
return outputPath
|
|
|
|
})
|
|
|
|
|
2022-03-22 14:35:04 +01:00
|
|
|
logger.info('Video edition ended for video %s.', video.uuid, lTags)
|
2022-02-11 10:51:33 +01:00
|
|
|
|
|
|
|
const newFile = await buildNewFile(video, editionResultPath)
|
|
|
|
|
|
|
|
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
|
|
|
|
await move(editionResultPath, outputPath)
|
|
|
|
|
|
|
|
await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
|
|
|
|
await removeAllFiles(video, newFile)
|
|
|
|
|
|
|
|
await newFile.save()
|
|
|
|
|
|
|
|
video.duration = await getVideoStreamDuration(outputPath)
|
|
|
|
await video.save()
|
|
|
|
|
|
|
|
await federateVideoIfNeeded(video, false, undefined)
|
|
|
|
|
2022-03-22 14:35:04 +01:00
|
|
|
const user = await UserModel.loadByVideoId(video.id)
|
2022-08-08 15:48:17 +02:00
|
|
|
|
|
|
|
await JobQueue.Instance.createJob(
|
|
|
|
await buildOptimizeOrMergeAudioJob({ video, videoFile: newFile, user, isNewVideo: false })
|
|
|
|
)
|
2022-02-11 10:51:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
export {
|
2022-03-22 16:58:49 +01:00
|
|
|
processVideoStudioEdition
|
2022-02-11 10:51:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
|
2022-02-11 10:51:33 +01:00
|
|
|
inputPath: string
|
|
|
|
outputPath: string
|
|
|
|
video: MVideo
|
|
|
|
task: T
|
2022-03-22 14:35:04 +01:00
|
|
|
lTags: { tags: string[] }
|
2022-02-11 10:51:33 +01:00
|
|
|
}
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
|
2022-02-11 10:51:33 +01:00
|
|
|
'add-intro': processAddIntroOutro,
|
|
|
|
'add-outro': processAddIntroOutro,
|
|
|
|
'cut': processCut,
|
|
|
|
'add-watermark': processAddWatermark
|
|
|
|
}
|
|
|
|
|
|
|
|
async function processTask (options: TaskProcessorOptions) {
|
|
|
|
const { video, task } = options
|
|
|
|
|
2022-03-22 14:35:04 +01:00
|
|
|
logger.info('Processing %s task for video %s.', task.name, video.uuid, { task, ...options.lTags })
|
2022-02-11 10:51:33 +01:00
|
|
|
|
|
|
|
const processor = taskProcessors[options.task.name]
|
|
|
|
if (!process) throw new Error('Unknown task ' + task.name)
|
|
|
|
|
|
|
|
return processor(options)
|
|
|
|
}
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
|
2022-02-11 10:51:33 +01:00
|
|
|
const { task } = options
|
|
|
|
|
|
|
|
return addIntroOutro({
|
|
|
|
...pick(options, [ 'inputPath', 'outputPath' ]),
|
|
|
|
|
|
|
|
introOutroPath: task.options.file,
|
|
|
|
type: task.name === 'add-intro'
|
|
|
|
? 'intro'
|
|
|
|
: 'outro',
|
|
|
|
|
|
|
|
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
|
|
|
|
profile: CONFIG.TRANSCODING.PROFILE
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
|
2022-02-11 10:51:33 +01:00
|
|
|
const { task } = options
|
|
|
|
|
|
|
|
return cutVideo({
|
|
|
|
...pick(options, [ 'inputPath', 'outputPath' ]),
|
|
|
|
|
|
|
|
start: task.options.start,
|
|
|
|
end: task.options.end
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
|
2022-02-11 10:51:33 +01:00
|
|
|
const { task } = options
|
|
|
|
|
|
|
|
return addWatermark({
|
|
|
|
...pick(options, [ 'inputPath', 'outputPath' ]),
|
|
|
|
|
|
|
|
watermarkPath: task.options.file,
|
|
|
|
|
|
|
|
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
|
|
|
|
profile: CONFIG.TRANSCODING.PROFILE
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async function buildNewFile (video: MVideoId, path: string) {
|
|
|
|
const videoFile = new VideoFileModel({
|
|
|
|
extname: getLowercaseExtension(path),
|
|
|
|
size: await getFileSize(path),
|
|
|
|
metadata: await buildFileMetadata(path),
|
|
|
|
videoStreamingPlaylistId: null,
|
|
|
|
videoId: video.id
|
|
|
|
})
|
|
|
|
|
|
|
|
const probe = await ffprobePromise(path)
|
|
|
|
|
|
|
|
videoFile.fps = await getVideoStreamFPS(path, probe)
|
|
|
|
videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
|
|
|
|
|
|
|
|
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
|
|
|
|
|
|
|
|
return videoFile
|
|
|
|
}
|
|
|
|
|
|
|
|
async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
|
2022-07-29 14:50:41 +02:00
|
|
|
await removeHLSPlaylist(video)
|
2022-02-11 10:51:33 +01:00
|
|
|
|
|
|
|
for (const file of video.VideoFiles) {
|
|
|
|
if (file.id === webTorrentFileException.id) continue
|
|
|
|
|
2022-07-29 14:50:41 +02:00
|
|
|
await removeWebTorrentFile(video, file.id)
|
2022-02-11 10:51:33 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoStudioEditionPayload) {
|
2022-02-11 10:51:33 +01:00
|
|
|
const user = await UserModel.loadByVideoId(video.id)
|
|
|
|
|
2022-03-22 16:58:49 +01:00
|
|
|
const filePathFinder = (i: number) => (payload.tasks[i] as VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload).options.file
|
2022-02-11 10:51:33 +01:00
|
|
|
|
|
|
|
const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
|
|
|
|
if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
|
|
|
|
throw new Error('Quota exceeded for this user to edit the video')
|
|
|
|
}
|
|
|
|
}
|