video-studio-edition.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import { Job } from 'bullmq'
  2. import { remove } from 'fs-extra/esm'
  3. import { join } from 'path'
  4. import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg/index.js'
  5. import { CONFIG } from '@server/initializers/config.js'
  6. import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles.js'
  7. import { isUserQuotaValid } from '@server/lib/user.js'
  8. import { VideoPathManager } from '@server/lib/video-path-manager.js'
  9. import { approximateIntroOutroAdditionalSize, onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio.js'
  10. import { UserModel } from '@server/models/user/user.js'
  11. import { VideoModel } from '@server/models/video/video.js'
  12. import { MVideo, MVideoFullLight } from '@server/types/models/index.js'
  13. import { pick } from '@peertube/peertube-core-utils'
  14. import { buildUUID } from '@peertube/peertube-node-utils'
  15. import { FFmpegEdition } from '@peertube/peertube-ffmpeg'
  16. import {
  17. VideoStudioEditionPayload,
  18. VideoStudioTask,
  19. VideoStudioTaskCutPayload,
  20. VideoStudioTaskIntroPayload,
  21. VideoStudioTaskOutroPayload,
  22. VideoStudioTaskPayload,
  23. VideoStudioTaskWatermarkPayload
  24. } from '@peertube/peertube-models'
  25. import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
  26. const lTagsBase = loggerTagsFactory('video-studio')
  27. async function processVideoStudioEdition (job: Job) {
  28. const payload = job.data as VideoStudioEditionPayload
  29. const lTags = lTagsBase(payload.videoUUID)
  30. logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags)
  31. try {
  32. const video = await VideoModel.loadFull(payload.videoUUID)
  33. // No video, maybe deleted?
  34. if (!video) {
  35. logger.info('Can\'t process job %d, video does not exist.', job.id, lTags)
  36. await safeCleanupStudioTMPFiles(payload.tasks)
  37. return undefined
  38. }
  39. await checkUserQuotaOrThrow(video, payload)
  40. const inputFile = video.getMaxQualityFile()
  41. const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
  42. let tmpInputFilePath: string
  43. let outputPath: string
  44. for (const task of payload.tasks) {
  45. const outputFilename = buildUUID() + inputFile.extname
  46. outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
  47. await processTask({
  48. inputPath: tmpInputFilePath ?? originalFilePath,
  49. video,
  50. outputPath,
  51. task,
  52. lTags
  53. })
  54. if (tmpInputFilePath) await remove(tmpInputFilePath)
  55. // For the next iteration
  56. tmpInputFilePath = outputPath
  57. }
  58. return outputPath
  59. })
  60. logger.info('Video edition ended for video %s.', video.uuid, lTags)
  61. await onVideoStudioEnded({ video, editionResultPath, tasks: payload.tasks })
  62. } catch (err) {
  63. await safeCleanupStudioTMPFiles(payload.tasks)
  64. throw err
  65. }
  66. }
  67. // ---------------------------------------------------------------------------
  68. export {
  69. processVideoStudioEdition
  70. }
  71. // ---------------------------------------------------------------------------
  72. type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
  73. inputPath: string
  74. outputPath: string
  75. video: MVideo
  76. task: T
  77. lTags: { tags: (string | number)[] }
  78. }
  79. const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
  80. 'add-intro': processAddIntroOutro,
  81. 'add-outro': processAddIntroOutro,
  82. 'cut': processCut,
  83. 'add-watermark': processAddWatermark
  84. }
  85. async function processTask (options: TaskProcessorOptions) {
  86. const { video, task, lTags } = options
  87. logger.info('Processing %s task for video %s.', task.name, video.uuid, { task, ...lTags })
  88. const processor = taskProcessors[options.task.name]
  89. if (!process) throw new Error('Unknown task ' + task.name)
  90. return processor(options)
  91. }
  92. function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
  93. const { task, lTags } = options
  94. logger.debug('Will add intro/outro to the video.', { options, ...lTags })
  95. return buildFFmpegEdition().addIntroOutro({
  96. ...pick(options, [ 'inputPath', 'outputPath' ]),
  97. introOutroPath: task.options.file,
  98. type: task.name === 'add-intro'
  99. ? 'intro'
  100. : 'outro'
  101. })
  102. }
  103. function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
  104. const { task, lTags } = options
  105. logger.debug('Will cut the video.', { options, ...lTags })
  106. return buildFFmpegEdition().cutVideo({
  107. ...pick(options, [ 'inputPath', 'outputPath' ]),
  108. start: task.options.start,
  109. end: task.options.end
  110. })
  111. }
  112. function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
  113. const { task, lTags } = options
  114. logger.debug('Will add watermark to the video.', { options, ...lTags })
  115. return buildFFmpegEdition().addWatermark({
  116. ...pick(options, [ 'inputPath', 'outputPath' ]),
  117. watermarkPath: task.options.file,
  118. videoFilters: {
  119. watermarkSizeRatio: task.options.watermarkSizeRatio,
  120. horitonzalMarginRatio: task.options.horitonzalMarginRatio,
  121. verticalMarginRatio: task.options.verticalMarginRatio
  122. }
  123. })
  124. }
  125. // ---------------------------------------------------------------------------
  126. async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoStudioEditionPayload) {
  127. const user = await UserModel.loadByVideoId(video.id)
  128. const filePathFinder = (i: number) => (payload.tasks[i] as VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload).options.file
  129. const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
  130. if (await isUserQuotaValid({ userId: user.id, uploadSize: additionalBytes }) === false) {
  131. throw new Error('Quota exceeded for this user to edit the video')
  132. }
  133. }
  134. function buildFFmpegEdition () {
  135. return new FFmpegEdition(getFFmpegCommandWrapperOptions('vod', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()))
  136. }