aboutsummaryrefslogblamecommitdiffhomepage
path: root/server/lib/job-queue/handlers/video-edition.ts
blob: c5ba0452fb56270d67f5c16f0df8ca391e8f2fdc (plain) (tree)




































































































































































































































                                                                                                                                    
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 <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
  inputPath: string
  outputPath: string
  video: MVideo
  task: T
}

const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
  '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<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
  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<VideoEditorTaskCutPayload>) {
  const { task } = options

  return cutVideo({
    ...pick(options, [ 'inputPath', 'outputPath' ]),

    start: task.options.start,
    end: task.options.end
  })
}

function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
  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')
  }
}