Chocobozzz/PeerTube

View on GitHub
server/core/lib/video-studio.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { VideoStudioEditionPayload, VideoStudioTask, VideoStudioTaskPayload } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { MUser, MVideoFile, MVideoFullLight, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models/index.js'
import { move, remove } from 'fs-extra/esm'
import { join } from 'path'
import { JobQueue } from './job-queue/index.js'
import { VideoStudioTranscodingJobHandler } from './runners/index.js'
import { getTranscodingJobPriority } from './transcoding/transcoding-priority.js'
import { createTranscriptionTaskIfNeeded } from './video-captions.js'
import { buildNewFile, removeHLSPlaylist, removeWebVideoFile } from './video-file.js'
import { buildStoryboardJobIfNeeded } from './video-jobs.js'
import { VideoPathManager } from './video-path-manager.js'

const lTags = loggerTagsFactory('video-studio')

export function buildTaskFileFieldname (indice: number, fieldName = 'file') {
  return `tasks[${indice}][options][${fieldName}]`
}

export function getTaskFileFromReq (files: Express.Multer.File[], indice: number, fieldName = 'file') {
  return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
}

export function getStudioTaskFilePath (filename: string) {
  return join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, filename)
}

export async function safeCleanupStudioTMPFiles (tasks: VideoStudioTaskPayload[]) {
  logger.info('Removing TMP studio task files', { tasks, ...lTags() })

  for (const task of tasks) {
    try {
      if (task.name === 'add-intro' || task.name === 'add-outro') {
        await remove(task.options.file)
      } else if (task.name === 'add-watermark') {
        await remove(task.options.file)
      }
    } catch (err) {
      logger.error('Cannot remove studio file', { err })
    }
  }
}

// ---------------------------------------------------------------------------

export async function approximateIntroOutroAdditionalSize (
  video: MVideoFullLight,
  tasks: VideoStudioTask[],
  fileFinder: (i: number) => string
) {
  let additionalDuration = 0

  for (let i = 0; i < tasks.length; i++) {
    const task = tasks[i]

    if (task.name !== 'add-intro' && task.name !== 'add-outro') continue

    const filePath = fileFinder(i)
    additionalDuration += await getVideoStreamDuration(filePath)
  }

  return (video.getMaxQualityBytes() / video.duration) * additionalDuration
}

// ---------------------------------------------------------------------------

export async function createVideoStudioJob (options: {
  video: MVideoWithFile
  user: MUser
  payload: VideoStudioEditionPayload
}) {
  const { video, user, payload } = options

  const priority = await getTranscodingJobPriority({ user, type: 'studio', fallback: 0 })

  if (CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED) {
    await new VideoStudioTranscodingJobHandler().create({ video, tasks: payload.tasks, priority })
    return
  }

  await JobQueue.Instance.createJob({ type: 'video-studio-edition', payload, priority })
}

export async function onVideoStudioEnded (options: {
  editionResultPath: string
  tasks: VideoStudioTaskPayload[]
  video: MVideoFullLight
}) {
  const { video, tasks, editionResultPath } = options

  const newFile = await buildNewFile({ path: editionResultPath, mode: 'web-video' })
  newFile.videoId = video.id

  const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
  await move(editionResultPath, outputPath)

  await safeCleanupStudioTMPFiles(tasks)

  await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
  await removeAllFiles(video, newFile)

  await newFile.save()

  video.duration = await getVideoStreamDuration(outputPath)
  video.aspectRatio = buildAspectRatio({ width: newFile.width, height: newFile.height })
  await video.save()

  await JobQueue.Instance.createSequentialJobFlow(
    buildStoryboardJobIfNeeded({ video, federate: false }),

    {
      type: 'federate-video' as 'federate-video',
      payload: {
        videoUUID: video.uuid,
        isNewVideoForFederation: false
      }
    },

    {
      type: 'transcoding-job-builder' as 'transcoding-job-builder',
      payload: {
        videoUUID: video.uuid,
        optimizeJob: {
          isNewVideo: false
        }
      }
    }
  )

  if (video.language && CONFIG.VIDEO_TRANSCRIPTION.ENABLED) {
    const caption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, video.language)

    if (caption?.automaticallyGenerated) {
      await createTranscriptionTaskIfNeeded(video)
    }
  }
}

// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------

async function removeAllFiles (video: MVideoWithAllFiles, webVideoFileException: MVideoFile) {
  await removeHLSPlaylist(video)

  for (const file of video.VideoFiles) {
    if (file.id === webVideoFileException.id) continue

    await removeWebVideoFile(video, file.id)
  }
}