From c729caf6cc34630877a0e5a1bda1719384cd0c8a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 11 Feb 2022 10:51:33 +0100 Subject: Add basic video editor support --- server/lib/job-queue/handlers/video-edition.ts | 229 +++++++++++++++++++++ server/lib/job-queue/handlers/video-file-import.ts | 12 +- server/lib/job-queue/handlers/video-import.ts | 8 +- server/lib/job-queue/handlers/video-live-ending.ts | 8 +- server/lib/job-queue/handlers/video-transcoding.ts | 10 +- server/lib/job-queue/job-queue.ts | 9 +- 6 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 server/lib/job-queue/handlers/video-edition.ts (limited to 'server/lib/job-queue') diff --git a/server/lib/job-queue/handlers/video-edition.ts b/server/lib/job-queue/handlers/video-edition.ts new file mode 100644 index 000000000..c5ba0452f --- /dev/null +++ b/server/lib/job-queue/handlers/video-edition.ts @@ -0,0 +1,229 @@ +import { Job } from 'bull' +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' +import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video' +import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor' +import { VideoPathManager } from '@server/lib/video-path-manager' +import { buildNextVideoState } from '@server/lib/video-state' +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 { + VideoEditionPayload, + VideoEditionTaskPayload, + VideoEditorTask, + VideoEditorTaskCutPayload, + VideoEditorTaskIntroPayload, + VideoEditorTaskOutroPayload, + VideoEditorTaskWatermarkPayload, + VideoState +} from '@shared/models' +import { logger, loggerTagsFactory } from '../../../helpers/logger' + +const lTagsBase = loggerTagsFactory('video-edition') + +async function processVideoEdition (job: Job) { + const payload = job.data as VideoEditionPayload + + logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id) + + const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID) + + // No video, maybe deleted? + if (!video) { + logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID)) + 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, + task + }) + + if (tmpInputFilePath) await remove(tmpInputFilePath) + + // For the next iteration + tmpInputFilePath = outputPath + } + + return outputPath + }) + + logger.info('Video edition ended for video %s.', video.uuid) + + 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.state = buildNextVideoState() + video.duration = await getVideoStreamDuration(outputPath) + await video.save() + + await federateVideoIfNeeded(video, false, undefined) + + if (video.state === VideoState.TO_TRANSCODE) { + const user = await UserModel.loadByVideoId(video.id) + + await addOptimizeOrMergeAudioJob(video, newFile, user, false) + } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { + await addMoveToObjectStorageJob(video, false) + } +} + +// --------------------------------------------------------------------------- + +export { + processVideoEdition +} + +// --------------------------------------------------------------------------- + +type TaskProcessorOptions = { + inputPath: string + outputPath: string + video: MVideo + task: T +} + +const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise } = { + 'add-intro': processAddIntroOutro, + 'add-outro': processAddIntroOutro, + 'cut': processCut, + 'add-watermark': processAddWatermark +} + +async function processTask (options: TaskProcessorOptions) { + const { video, task } = options + + logger.info('Processing %s task for video %s.', task.name, video.uuid, { task }) + + const processor = taskProcessors[options.task.name] + if (!process) throw new Error('Unknown task ' + task.name) + + return processor(options) +} + +function processAddIntroOutro (options: TaskProcessorOptions) { + 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 + }) +} + +function processCut (options: TaskProcessorOptions) { + const { task } = options + + return cutVideo({ + ...pick(options, [ 'inputPath', 'outputPath' ]), + + start: task.options.start, + end: task.options.end + }) +} + +function processAddWatermark (options: TaskProcessorOptions) { + 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) { + const hls = video.getHLSPlaylist() + + if (hls) { + await video.removeStreamingPlaylistFiles(hls) + await hls.destroy() + } + + for (const file of video.VideoFiles) { + if (file.id === webTorrentFileException.id) continue + + await video.removeWebTorrentFileAndTorrent(file) + await file.destroy() + } +} + +async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) { + const user = await UserModel.loadByVideoId(video.id) + + const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file + + 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') + } +} diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts index 0d9e80cb8..6b2d60317 100644 --- a/server/lib/job-queue/handlers/video-file-import.ts +++ b/server/lib/job-queue/handlers/video-file-import.ts @@ -1,18 +1,18 @@ import { Job } from 'bull' import { copy, stat } from 'fs-extra' -import { getLowercaseExtension } from '@shared/core-utils' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { CONFIG } from '@server/initializers/config' import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' import { generateWebTorrentVideoFilename } from '@server/lib/paths' import { addMoveToObjectStorageJob } from '@server/lib/video' import { VideoPathManager } from '@server/lib/video-path-manager' +import { VideoModel } from '@server/models/video/video' +import { VideoFileModel } from '@server/models/video/video-file' import { MVideoFullLight } from '@server/types/models' +import { getLowercaseExtension } from '@shared/core-utils' import { VideoFileImportPayload, VideoStorage } from '@shared/models' -import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' +import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg' import { logger } from '../../../helpers/logger' -import { VideoModel } from '../../../models/video/video' -import { VideoFileModel } from '../../../models/video/video-file' async function processVideoFileImport (job: Job) { const payload = job.data as VideoFileImportPayload @@ -45,9 +45,9 @@ export { // --------------------------------------------------------------------------- async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { - const { resolution } = await getVideoFileResolution(inputFilePath) + const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath) const { size } = await stat(inputFilePath) - const fps = await getVideoFileFPS(inputFilePath) + const fps = await getVideoStreamFPS(inputFilePath) const fileExt = getLowercaseExtension(inputFilePath) diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index b6e05d8f5..b3ca28c2f 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -25,7 +25,7 @@ import { VideoResolution, VideoState } from '@shared/models' -import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' +import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg' import { logger } from '../../../helpers/logger' import { getSecureTorrentName } from '../../../helpers/utils' import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent' @@ -121,10 +121,10 @@ async function processFile (downloader: () => Promise, videoImport: MVid const { resolution } = await isAudioFile(tempVideoPath, probe) ? { resolution: VideoResolution.H_NOVIDEO } - : await getVideoFileResolution(tempVideoPath) + : await getVideoStreamDimensionsInfo(tempVideoPath) - const fps = await getVideoFileFPS(tempVideoPath, probe) - const duration = await getDurationFromVideoFile(tempVideoPath, probe) + const fps = await getVideoStreamFPS(tempVideoPath, probe) + const duration = await getVideoStreamDuration(tempVideoPath, probe) // Prepare video file object for creation in database const fileExt = getLowercaseExtension(tempVideoPath) diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index a04cfa2c9..497f6612a 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -1,12 +1,12 @@ import { Job } from 'bull' import { pathExists, readdir, remove } from 'fs-extra' import { join } from 'path' -import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' +import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg' import { VIDEO_LIVE } from '@server/initializers/constants' import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live' import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths' import { generateVideoMiniature } from '@server/lib/thumbnail' -import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding' +import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' import { VideoPathManager } from '@server/lib/video-path-manager' import { moveToNextState } from '@server/lib/video-state' import { VideoModel } from '@server/models/video/video' @@ -96,7 +96,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt const probe = await ffprobePromise(concatenatedTsFilePath) const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) - const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe) + const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe) const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({ video: videoWithFiles, @@ -107,7 +107,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt }) if (!durationDone) { - videoWithFiles.duration = await getDurationFromVideoFile(outputPath) + videoWithFiles.duration = await getVideoStreamDuration(outputPath) await videoWithFiles.save() durationDone = true diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 5540b791d..512979734 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -1,5 +1,5 @@ import { Job } from 'bull' -import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils' +import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg' import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video' import { VideoPathManager } from '@server/lib/video-path-manager' import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state' @@ -16,7 +16,7 @@ import { VideoTranscodingPayload } from '@shared/models' import { retryTransactionWrapper } from '../../../helpers/database-utils' -import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils' +import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg' import { logger, loggerTagsFactory } from '../../../helpers/logger' import { CONFIG } from '../../../initializers/config' import { VideoModel } from '../../../models/video/video' @@ -25,7 +25,7 @@ import { mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewWebTorrentResolution -} from '../../transcoding/video-transcoding' +} from '../../transcoding/transcoding' type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise @@ -174,10 +174,10 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay async function onVideoFirstWebTorrentTranscoding ( videoArg: MVideoWithFile, payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload, - transcodeType: TranscodeOptionsType, + transcodeType: TranscodeVODOptionsType, user: MUserId ) { - const { resolution, isPortraitMode, audioStream } = await videoArg.getMaxQualityFileInfo() + const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile() // Maybe the video changed in database, refresh it const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid) diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 22bd1f5d2..e10a3bab5 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -14,6 +14,7 @@ import { JobType, MoveObjectStoragePayload, RefreshPayload, + VideoEditionPayload, VideoFileImportPayload, VideoImportPayload, VideoLiveEndingPayload, @@ -31,6 +32,7 @@ import { refreshAPObject } from './handlers/activitypub-refresher' import { processActorKeys } from './handlers/actor-keys' import { processEmail } from './handlers/email' import { processMoveToObjectStorage } from './handlers/move-to-object-storage' +import { processVideoEdition } from './handlers/video-edition' import { processVideoFileImport } from './handlers/video-file-import' import { processVideoImport } from './handlers/video-import' import { processVideoLiveEnding } from './handlers/video-live-ending' @@ -53,6 +55,7 @@ type CreateJobArgument = { type: 'actor-keys', payload: ActorKeysPayload } | { type: 'video-redundancy', payload: VideoRedundancyPayload } | { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } | + { type: 'video-edition', payload: VideoEditionPayload } | { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } export type CreateJobOptions = { @@ -75,7 +78,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise } = { 'video-live-ending': processVideoLiveEnding, 'actor-keys': processActorKeys, 'video-redundancy': processVideoRedundancy, - 'move-to-object-storage': processMoveToObjectStorage + 'move-to-object-storage': processMoveToObjectStorage, + 'video-edition': processVideoEdition } const jobTypes: JobType[] = [ @@ -93,7 +97,8 @@ const jobTypes: JobType[] = [ 'video-redundancy', 'actor-keys', 'video-live-ending', - 'move-to-object-storage' + 'move-to-object-storage', + 'video-edition' ] class JobQueue { -- cgit v1.2.3