]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Support studio transcoding in peertube runner
authorChocobozzz <me@florianbigard.com>
Thu, 4 May 2023 13:29:34 +0000 (15:29 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 9 May 2023 06:57:34 +0000 (08:57 +0200)
67 files changed:
client/src/app/+admin/admin.component.ts
client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
config/default.yaml
config/production.yaml.example
packages/peertube-runner/server/process/process.ts
packages/peertube-runner/server/process/shared/common.ts
packages/peertube-runner/server/process/shared/process-studio.ts [new file with mode: 0644]
packages/peertube-runner/server/process/shared/process-vod.ts
packages/peertube-runner/server/server.ts
packages/peertube-runner/server/shared/index.ts [new file with mode: 0644]
packages/peertube-runner/server/shared/supported-job.ts [new file with mode: 0644]
server/controllers/api/config.ts
server/controllers/api/runners/jobs-files.ts
server/controllers/api/runners/jobs.ts
server/controllers/api/videos/studio.ts
server/helpers/custom-validators/misc.ts
server/helpers/custom-validators/runners/jobs.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/lib/job-queue/handlers/video-studio-edition.ts
server/lib/runners/job-handlers/abstract-job-handler.ts
server/lib/runners/job-handlers/abstract-vod-transcoding-job-handler.ts
server/lib/runners/job-handlers/index.ts
server/lib/runners/job-handlers/live-rtmp-hls-transcoding-job-handler.ts
server/lib/runners/job-handlers/runner-job-handlers.ts
server/lib/runners/job-handlers/video-edition-transcoding-job-handler.ts [new file with mode: 0644]
server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts
server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts
server/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts
server/lib/runners/runner-urls.ts
server/lib/server-config-manager.ts
server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
server/lib/transcoding/transcoding-priority.ts [new file with mode: 0644]
server/lib/video-studio.ts
server/middlewares/validators/config.ts
server/middlewares/validators/runners/job-files.ts
server/middlewares/validators/runners/jobs.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/runners.ts
server/tests/api/runners/index.ts
server/tests/api/runners/runner-common.ts
server/tests/api/runners/runner-studio-transcoding.ts [new file with mode: 0644]
server/tests/api/runners/runner-vod-transcoding.ts
server/tests/api/server/config.ts
server/tests/api/transcoding/video-studio.ts
server/tests/peertube-runner/index.ts
server/tests/peertube-runner/live-transcoding.ts
server/tests/peertube-runner/studio-transcoding.ts [new file with mode: 0644]
server/tests/peertube-runner/vod-transcoding.ts
server/tests/shared/checks.ts
server/tests/shared/directories.ts
shared/models/runners/runner-job-payload.model.ts
shared/models/runners/runner-job-private-payload.model.ts
shared/models/runners/runner-job-success-body.model.ts
shared/models/runners/runner-job-type.type.ts
shared/models/server/custom-config.model.ts
shared/models/server/job.model.ts
shared/models/server/server-config.model.ts
shared/models/videos/studio/video-studio-create-edit.model.ts
shared/server-commands/runners/runner-jobs-command.ts
shared/server-commands/server/config-command.ts

index d4d912c4067b91a4e2cbdda91e379a0709cd87bf..49092ea2aa3b959bf62beb78b123e594ce1e7c21 100644 (file)
@@ -272,6 +272,8 @@ export class AdminComponent implements OnInit {
   private isRemoteRunnersEnabled () {
     const config = this.server.getHTMLConfig()
 
-    return config.transcoding.remoteRunners.enabled || config.live.transcoding.remoteRunners.enabled
+    return config.transcoding.remoteRunners.enabled ||
+      config.live.transcoding.remoteRunners.enabled ||
+      config.videoStudio.remoteRunners.enabled
   }
 }
index 96f5b830e964a45a8d59461985b73a91583dcdf3..6c431ce644a64441f9324bda6a810421f25832ba 100644 (file)
@@ -61,6 +61,10 @@ export class EditConfigurationService {
     return form.value['transcoding']['enabled'] === true
   }
 
+  isStudioEnabled (form: FormGroup) {
+    return form.value['videoStudio']['enabled'] === true
+  }
+
   isLiveEnabled (form: FormGroup) {
     return form.value['live']['enabled'] === true
   }
index 335aedb67aee2e437c37b5f9c93e8fe74c17c86e..30e4aa5d54d29aa86e1899dcea02ef44080089d0 100644 (file)
@@ -218,7 +218,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
         }
       },
       videoStudio: {
-        enabled: null
+        enabled: null,
+        remoteRunners: {
+          enabled: null
+        }
       },
       autoBlacklist: {
         videos: {
index c11f560dd7e9d49b0e787474b09f37c985d1112e..b17c51532c4475f9530f482c6c2a61df7dc4e67a 100644 (file)
             </ng-container>
           </my-peertube-checkbox>
         </div>
+
+        <div class="form-group" formGroupName="remoteRunners" [ngClass]="getStudioDisabledClass()">
+          <my-peertube-checkbox
+            inputName="videoStudioRemoteRunnersEnabled" formControlName="enabled"
+            i18n-labelText labelText="Enable remote runners"
+          >
+            <ng-container ngProjectAs="description">
+              <span i18n>
+                Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process studio transcoding tasks.
+                Remote runners has to register on your instance first.
+              </span>
+            </ng-container>
+          </my-peertube-checkbox>
+        </div>
       </ng-container>
     </div>
   </div>
index 184dfd9211bfa96bf065b74d9742c3da384ae2ec..e960533f9809db750b6d9743f533bbc04f0981d1 100644 (file)
@@ -62,10 +62,18 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
     return this.editConfigurationService.isTranscodingEnabled(this.form)
   }
 
+  isStudioEnabled () {
+    return this.editConfigurationService.isStudioEnabled(this.form)
+  }
+
   getTranscodingDisabledClass () {
     return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
   }
 
+  getStudioDisabledClass () {
+    return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
+  }
+
   getTotalTranscodingThreads () {
     return this.editConfigurationService.getTotalTranscodingThreads(this.form)
   }
index f3f29ecb98dde7134c9085623614de36757ca676..14bb8d060be5f0f6d4eec89f36d78bb727675323 100644 (file)
@@ -579,6 +579,12 @@ video_studio:
   # If enabled, users can create transcoding tasks as they wish
   enabled: false
 
+  # Enable remote runners to transcode studio tasks
+  # If enabled, your instance won't transcode the videos itself
+  # At least 1 remote runner must be configured to transcode your videos
+  remote_runners:
+    enabled: false
+
 import:
   # Add ability for your users to import remote videos (from YouTube, torrent...)
   videos:
index ea6d773063e5ad8632b74bb51595d8a2eb9e1f73..db9c18cb833c16f63984248efb76c7b6dffc8864 100644 (file)
@@ -589,6 +589,13 @@ video_studio:
   # If enabled, users can create transcoding tasks as they wish
   enabled: false
 
+
+  # Enable remote runners to transcode studio tasks
+  # If enabled, your instance won't transcode the videos itself
+  # At least 1 remote runner must be configured to transcode your videos
+  remote_runners:
+    enabled: false
+
 import:
   # Add ability for your users to import remote videos (from YouTube, torrent...)
   videos:
index 39a929c59c1bb327ea0780021076b6954e6826ba..ef231cb38fff54ee2496812e3ca427eb744388bb 100644 (file)
@@ -1,12 +1,14 @@
 import { logger } from 'packages/peertube-runner/shared/logger'
 import {
   RunnerJobLiveRTMPHLSTranscodingPayload,
+  RunnerJobVideoEditionTranscodingPayload,
   RunnerJobVODAudioMergeTranscodingPayload,
   RunnerJobVODHLSTranscodingPayload,
   RunnerJobVODWebVideoTranscodingPayload
 } from '@shared/models'
 import { processAudioMergeTranscoding, processHLSTranscoding, ProcessOptions, processWebVideoTranscoding } from './shared'
 import { ProcessLiveRTMPHLSTranscoding } from './shared/process-live'
+import { processStudioTranscoding } from './shared/process-studio'
 
 export async function processJob (options: ProcessOptions) {
   const { server, job } = options
@@ -21,6 +23,8 @@ export async function processJob (options: ProcessOptions) {
     await processHLSTranscoding(options as ProcessOptions<RunnerJobVODHLSTranscodingPayload>)
   } else if (job.type === 'live-rtmp-hls-transcoding') {
     await new ProcessLiveRTMPHLSTranscoding(options as ProcessOptions<RunnerJobLiveRTMPHLSTranscodingPayload>).process()
+  } else if (job.type === 'video-edition-transcoding') {
+    await processStudioTranscoding(options as ProcessOptions<RunnerJobVideoEditionTranscodingPayload>)
   } else {
     logger.error(`Unknown job ${job.type} to process`)
     return
index 9b2c40728cb61c4a959e6c237e9d362ff4c4a731..3cac983884becedf0bff562a3aeb0d9f463b547f 100644 (file)
@@ -2,11 +2,12 @@ import { throttle } from 'lodash'
 import { ConfigManager, downloadFile, logger } from 'packages/peertube-runner/shared'
 import { join } from 'path'
 import { buildUUID } from '@shared/extra-utils'
-import { FFmpegLive, FFmpegVOD } from '@shared/ffmpeg'
+import { FFmpegEdition, FFmpegLive, FFmpegVOD } from '@shared/ffmpeg'
 import { RunnerJob, RunnerJobPayload } from '@shared/models'
 import { PeerTubeServer } from '@shared/server-commands'
 import { getTranscodingLogger } from './transcoding-logger'
 import { getAvailableEncoders, getEncodersToTry } from './transcoding-profiles'
+import { remove } from 'fs-extra'
 
 export type JobWithToken <T extends RunnerJobPayload = RunnerJobPayload> = RunnerJob<T> & { jobToken: string }
 
@@ -24,7 +25,14 @@ export async function downloadInputFile (options: {
   const { url, job, runnerToken } = options
   const destination = join(ConfigManager.Instance.getTranscodingDirectory(), buildUUID())
 
-  await downloadFile({ url, jobToken: job.jobToken, runnerToken, destination })
+  try {
+    await downloadFile({ url, jobToken: job.jobToken, runnerToken, destination })
+  } catch (err) {
+    remove(destination)
+      .catch(err => logger.error({ err }, `Cannot remove ${destination}`))
+
+    throw err
+  }
 
   return destination
 }
@@ -40,6 +48,8 @@ export async function updateTranscodingProgress (options: {
   return server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress })
 }
 
+// ---------------------------------------------------------------------------
+
 export function buildFFmpegVOD (options: {
   server: PeerTubeServer
   runnerToken: string
@@ -58,26 +68,25 @@ export function buildFFmpegVOD (options: {
       .catch(err => logger.error({ err }, 'Cannot send job progress'))
   }, updateInterval, { trailing: false })
 
-  const config = ConfigManager.Instance.getConfig()
-
   return new FFmpegVOD({
-    niceness: config.ffmpeg.nice,
-    threads: config.ffmpeg.threads,
-    tmpDirectory: ConfigManager.Instance.getTranscodingDirectory(),
-    profile: 'default',
-    availableEncoders: {
-      available: getAvailableEncoders(),
-      encodersToTry: getEncodersToTry()
-    },
-    logger: getTranscodingLogger(),
+    ...getCommonFFmpegOptions(),
+
     updateJobProgress
   })
 }
 
 export function buildFFmpegLive () {
+  return new FFmpegLive(getCommonFFmpegOptions())
+}
+
+export function buildFFmpegEdition () {
+  return new FFmpegEdition(getCommonFFmpegOptions())
+}
+
+function getCommonFFmpegOptions () {
   const config = ConfigManager.Instance.getConfig()
 
-  return new FFmpegLive({
+  return {
     niceness: config.ffmpeg.nice,
     threads: config.ffmpeg.threads,
     tmpDirectory: ConfigManager.Instance.getTranscodingDirectory(),
@@ -87,5 +96,5 @@ export function buildFFmpegLive () {
       encodersToTry: getEncodersToTry()
     },
     logger: getTranscodingLogger()
-  })
+  }
 }
diff --git a/packages/peertube-runner/server/process/shared/process-studio.ts b/packages/peertube-runner/server/process/shared/process-studio.ts
new file mode 100644 (file)
index 0000000..f826209
--- /dev/null
@@ -0,0 +1,138 @@
+import { remove } from 'fs-extra'
+import { pick } from 'lodash'
+import { logger } from 'packages/peertube-runner/shared'
+import { extname, join } from 'path'
+import { buildUUID } from '@shared/extra-utils'
+import {
+  RunnerJobVideoEditionTranscodingPayload,
+  VideoEditionTranscodingSuccess,
+  VideoStudioTask,
+  VideoStudioTaskCutPayload,
+  VideoStudioTaskIntroPayload,
+  VideoStudioTaskOutroPayload,
+  VideoStudioTaskPayload,
+  VideoStudioTaskWatermarkPayload
+} from '@shared/models'
+import { ConfigManager } from '../../../shared/config-manager'
+import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions } from './common'
+
+export async function processStudioTranscoding (options: ProcessOptions<RunnerJobVideoEditionTranscodingPayload>) {
+  const { server, job, runnerToken } = options
+  const payload = job.payload
+
+  let outputPath: string
+  const inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
+  let tmpInputFilePath = inputPath
+
+  try {
+    for (const task of payload.tasks) {
+      const outputFilename = 'output-edition-' + buildUUID() + '.mp4'
+      outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
+
+      await processTask({
+        inputPath: tmpInputFilePath,
+        outputPath,
+        task,
+        job,
+        runnerToken
+      })
+
+      if (tmpInputFilePath) await remove(tmpInputFilePath)
+
+      // For the next iteration
+      tmpInputFilePath = outputPath
+    }
+
+    const successBody: VideoEditionTranscodingSuccess = {
+      videoFile: outputPath
+    }
+
+    await server.runnerJobs.success({
+      jobToken: job.jobToken,
+      jobUUID: job.uuid,
+      runnerToken,
+      payload: successBody
+    })
+  } finally {
+    await remove(tmpInputFilePath)
+    await remove(outputPath)
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
+  inputPath: string
+  outputPath: string
+  task: T
+  runnerToken: string
+  job: JobWithToken
+}
+
+const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
+  'add-intro': processAddIntroOutro,
+  'add-outro': processAddIntroOutro,
+  'cut': processCut,
+  'add-watermark': processAddWatermark
+}
+
+async function processTask (options: TaskProcessorOptions) {
+  const { task } = options
+
+  const processor = taskProcessors[options.task.name]
+  if (!process) throw new Error('Unknown task ' + task.name)
+
+  return processor(options)
+}
+
+async function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
+  const { inputPath, task, runnerToken, job } = options
+
+  logger.debug('Adding intro/outro to ' + inputPath)
+
+  const introOutroPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
+
+  return buildFFmpegEdition().addIntroOutro({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    introOutroPath,
+    type: task.name === 'add-intro'
+      ? 'intro'
+      : 'outro'
+  })
+}
+
+function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
+  const { inputPath, task } = options
+
+  logger.debug(`Cutting ${inputPath}`)
+
+  return buildFFmpegEdition().cutVideo({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    start: task.options.start,
+    end: task.options.end
+  })
+}
+
+async function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
+  const { inputPath, task, runnerToken, job } = options
+
+  logger.debug('Adding watermark to ' + inputPath)
+
+  const watermarkPath = await downloadInputFile({ url: task.options.file, runnerToken, job })
+
+  return buildFFmpegEdition().addWatermark({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    watermarkPath,
+
+    videoFilters: {
+      watermarkSizeRatio: task.options.watermarkSizeRatio,
+      horitonzalMarginRatio: task.options.horitonzalMarginRatio,
+      verticalMarginRatio: task.options.verticalMarginRatio
+    }
+  })
+}
index aae61e9c53bcbcf8c90e74caede53213dbbd6627..d84ece3cbcc51c3d81b4e8380557add9dbe90dc7 100644 (file)
@@ -62,33 +62,36 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
 
   const ffmpegVod = buildFFmpegVOD({ job, server, runnerToken })
 
-  await ffmpegVod.transcode({
-    type: 'hls',
-    copyCodecs: false,
-    inputPath,
-    hlsPlaylist: { videoFilename },
-    outputPath,
-
-    inputFileMutexReleaser: () => {},
-
-    resolution: payload.output.resolution,
-    fps: payload.output.fps
-  })
-
-  const successBody: VODHLSTranscodingSuccess = {
-    resolutionPlaylistFile: outputPath,
-    videoFile: videoPath
+  try {
+    await ffmpegVod.transcode({
+      type: 'hls',
+      copyCodecs: false,
+      inputPath,
+      hlsPlaylist: { videoFilename },
+      outputPath,
+
+      inputFileMutexReleaser: () => {},
+
+      resolution: payload.output.resolution,
+      fps: payload.output.fps
+    })
+
+    const successBody: VODHLSTranscodingSuccess = {
+      resolutionPlaylistFile: outputPath,
+      videoFile: videoPath
+    }
+
+    await server.runnerJobs.success({
+      jobToken: job.jobToken,
+      jobUUID: job.uuid,
+      runnerToken,
+      payload: successBody
+    })
+  } finally {
+    await remove(inputPath)
+    await remove(outputPath)
+    await remove(videoPath)
   }
-
-  await server.runnerJobs.success({
-    jobToken: job.jobToken,
-    jobUUID: job.uuid,
-    runnerToken,
-    payload: successBody
-  })
-
-  await remove(outputPath)
-  await remove(videoPath)
 }
 
 export async function processAudioMergeTranscoding (options: ProcessOptions<RunnerJobVODAudioMergeTranscodingPayload>) {
index e851dfc7cc743521a217c357c101760949dd4394..8eff4bd2f4f1600a99c7006d44467ba33c1fb635 100644 (file)
@@ -8,6 +8,7 @@ import { ConfigManager } from '../shared'
 import { IPCServer } from '../shared/ipc'
 import { logger } from '../shared/logger'
 import { JobWithToken, processJob } from './process'
+import { isJobSupported } from './shared'
 
 type PeerTubeServer = PeerTubeServerCommand & {
   runnerToken: string
@@ -199,12 +200,14 @@ export class RunnerServer {
 
     const { availableJobs } = await server.runnerJobs.request({ runnerToken: server.runnerToken })
 
-    if (availableJobs.length === 0) {
+    const filtered = availableJobs.filter(j => isJobSupported(j))
+
+    if (filtered.length === 0) {
       logger.debug(`No job available on ${server.url} for runner ${server.runnerName}`)
       return undefined
     }
 
-    return availableJobs[0]
+    return filtered[0]
   }
 
   private async tryToExecuteJobAsync (server: PeerTubeServer, jobToAccept: { uuid: string }) {
diff --git a/packages/peertube-runner/server/shared/index.ts b/packages/peertube-runner/server/shared/index.ts
new file mode 100644 (file)
index 0000000..5c86baf
--- /dev/null
@@ -0,0 +1 @@
+export * from './supported-job'
diff --git a/packages/peertube-runner/server/shared/supported-job.ts b/packages/peertube-runner/server/shared/supported-job.ts
new file mode 100644 (file)
index 0000000..87d5a39
--- /dev/null
@@ -0,0 +1,43 @@
+import {
+  RunnerJobLiveRTMPHLSTranscodingPayload,
+  RunnerJobPayload,
+  RunnerJobType,
+  RunnerJobVideoEditionTranscodingPayload,
+  RunnerJobVODAudioMergeTranscodingPayload,
+  RunnerJobVODHLSTranscodingPayload,
+  RunnerJobVODWebVideoTranscodingPayload,
+  VideoStudioTaskPayload
+} from '@shared/models'
+
+const supportedMatrix = {
+  'vod-web-video-transcoding': (_payload: RunnerJobVODWebVideoTranscodingPayload) => {
+    return true
+  },
+  'vod-hls-transcoding': (_payload: RunnerJobVODHLSTranscodingPayload) => {
+    return true
+  },
+  'vod-audio-merge-transcoding': (_payload: RunnerJobVODAudioMergeTranscodingPayload) => {
+    return true
+  },
+  'live-rtmp-hls-transcoding': (_payload: RunnerJobLiveRTMPHLSTranscodingPayload) => {
+    return true
+  },
+  'video-edition-transcoding': (payload: RunnerJobVideoEditionTranscodingPayload) => {
+    const tasks = payload?.tasks
+    const supported = new Set<VideoStudioTaskPayload['name']>([ 'add-intro', 'add-outro', 'add-watermark', 'cut' ])
+
+    if (!Array.isArray(tasks)) return false
+
+    return tasks.every(t => t && supported.has(t.name))
+  }
+}
+
+export function isJobSupported (job: {
+  type: RunnerJobType
+  payload: RunnerJobPayload
+}) {
+  const fn = supportedMatrix[job.type]
+  if (!fn) return false
+
+  return fn(job.payload as any)
+}
index 0b9aaffdab09e849c01454180f2f2baee204d28e..3b6230f4acdd2df3b4900246f393faf83d077945 100644 (file)
@@ -274,7 +274,10 @@ function customConfig (): CustomConfig {
       }
     },
     videoStudio: {
-      enabled: CONFIG.VIDEO_STUDIO.ENABLED
+      enabled: CONFIG.VIDEO_STUDIO.ENABLED,
+      remoteRunners: {
+        enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
+      }
     },
     import: {
       videos: {
index e43ce35f5fc6be5245854344a20a03477ea5a3a8..4efa40b3ad289b4535d90312c095d5ba1b7eea0b 100644 (file)
@@ -2,9 +2,13 @@ import express from 'express'
 import { logger, loggerTagsFactory } from '@server/helpers/logger'
 import { proxifyHLS, proxifyWebTorrentFile } from '@server/lib/object-storage'
 import { VideoPathManager } from '@server/lib/video-path-manager'
+import { getStudioTaskFilePath } from '@server/lib/video-studio'
 import { asyncMiddleware } from '@server/middlewares'
 import { jobOfRunnerGetValidator } from '@server/middlewares/validators/runners'
-import { runnerJobGetVideoTranscodingFileValidator } from '@server/middlewares/validators/runners/job-files'
+import {
+  runnerJobGetVideoStudioTaskFileValidator,
+  runnerJobGetVideoTranscodingFileValidator
+} from '@server/middlewares/validators/runners/job-files'
 import { VideoStorage } from '@shared/models'
 
 const lTags = loggerTagsFactory('api', 'runner')
@@ -23,6 +27,13 @@ runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/previews/max-qua
   getMaxQualityVideoPreview
 )
 
+runnerJobFilesRouter.post('/jobs/:jobUUID/files/videos/:videoId/studio/task-files/:filename',
+  asyncMiddleware(jobOfRunnerGetValidator),
+  asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
+  runnerJobGetVideoStudioTaskFileValidator,
+  getVideoEditionTaskFile
+)
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -82,3 +93,17 @@ function getMaxQualityVideoPreview (req: express.Request, res: express.Response)
 
   return res.sendFile(file.getPath())
 }
+
+function getVideoEditionTaskFile (req: express.Request, res: express.Response) {
+  const runnerJob = res.locals.runnerJob
+  const runner = runnerJob.Runner
+  const video = res.locals.videoAll
+  const filename = req.params.filename
+
+  logger.info(
+    'Get video edition task file %s of video %s of job %s for runner %s', filename, video.uuid, runnerJob.uuid, runner.name,
+    lTags(runner.name, runnerJob.id, runnerJob.type)
+  )
+
+  return res.sendFile(getStudioTaskFilePath(filename))
+}
index 7d488ec111403b2c2d6f9f16eec966f0b6bfba53..8e34c07a38ee0a79d3fe5e29edb4936d97fa75ca 100644 (file)
@@ -17,6 +17,7 @@ import {
 import {
   abortRunnerJobValidator,
   acceptRunnerJobValidator,
+  cancelRunnerJobValidator,
   errorRunnerJobValidator,
   getRunnerFromTokenValidator,
   jobOfRunnerGetValidator,
@@ -41,6 +42,7 @@ import {
   RunnerJobUpdateBody,
   RunnerJobUpdatePayload,
   UserRight,
+  VideoEditionTranscodingSuccess,
   VODAudioMergeTranscodingSuccess,
   VODHLSTranscodingSuccess,
   VODWebVideoTranscodingSuccess
@@ -110,6 +112,7 @@ runnerJobsRouter.post('/jobs/:jobUUID/cancel',
   authenticate,
   ensureUserHasRight(UserRight.MANAGE_RUNNERS),
   asyncMiddleware(runnerJobGetValidator),
+  cancelRunnerJobValidator,
   asyncMiddleware(cancelRunnerJob)
 )
 
@@ -297,6 +300,14 @@ const jobSuccessPayloadBuilders: {
     }
   },
 
+  'video-edition-transcoding': (payload: VideoEditionTranscodingSuccess, files) => {
+    return {
+      ...payload,
+
+      videoFile: files['payload[videoFile]'][0].path
+    }
+  },
+
   'live-rtmp-hls-transcoding': () => ({})
 }
 
@@ -327,7 +338,7 @@ async function postRunnerJobSuccess (req: express.Request, res: express.Response
 async function cancelRunnerJob (req: express.Request, res: express.Response) {
   const runnerJob = res.locals.runnerJob
 
-  logger.info('Cancelling job %s (%s)', runnerJob.type, lTags(runnerJob.uuid, runnerJob.type))
+  logger.info('Cancelling job %s (%s)', runnerJob.uuid, runnerJob.type, lTags(runnerJob.uuid, runnerJob.type))
 
   const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
   await new RunnerJobHandler().cancel({ runnerJob })
index 2ccb2fb89fc548eff419e2f209c9d765f5c204a5..7c31dfd2be9fe77a37899be1825b15467695a542 100644 (file)
@@ -1,12 +1,10 @@
 import Bluebird from 'bluebird'
 import express from 'express'
 import { move } from 'fs-extra'
-import { basename, join } from 'path'
+import { basename } from 'path'
 import { createAnyReqFiles } from '@server/helpers/express-utils'
-import { CONFIG } from '@server/initializers/config'
-import { MIMETYPES } from '@server/initializers/constants'
-import { JobQueue } from '@server/lib/job-queue'
-import { buildTaskFileFieldname, getTaskFileFromReq } from '@server/lib/video-studio'
+import { MIMETYPES, VIDEO_FILTERS } from '@server/initializers/constants'
+import { buildTaskFileFieldname, createVideoStudioJob, getStudioTaskFilePath, getTaskFileFromReq } from '@server/lib/video-studio'
 import {
   HttpStatusCode,
   VideoState,
@@ -75,7 +73,11 @@ async function createEditionTasks (req: express.Request, res: express.Response)
     tasks: await Bluebird.mapSeries(body.tasks, (t, i) => buildTaskPayload(t, i, files))
   }
 
-  JobQueue.Instance.createJobAsync({ type: 'video-studio-edition', payload })
+  await createVideoStudioJob({
+    user: res.locals.oauth.token.User,
+    payload,
+    video
+  })
 
   return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
 }
@@ -124,13 +126,16 @@ async function buildWatermarkTask (task: VideoStudioTaskWatermark, indice: numbe
   return {
     name: task.name,
     options: {
-      file: destination
+      file: destination,
+      watermarkSizeRatio: VIDEO_FILTERS.WATERMARK.SIZE_RATIO,
+      horitonzalMarginRatio: VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO,
+      verticalMarginRatio: VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO
     }
   }
 }
 
 async function moveStudioFileToPersistentTMP (file: string) {
-  const destination = join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, basename(file))
+  const destination = getStudioTaskFilePath(basename(file))
 
   await move(file, destination)
 
index fa0f469f609c81546d40aa720e6b835a46529b77..2c4cd1b9f22f085c3910951f54c6ddc327c748a4 100644 (file)
@@ -15,8 +15,12 @@ function isSafePath (p: string) {
     })
 }
 
-function isSafeFilename (filename: string, extension: string) {
-  return typeof filename === 'string' && !!filename.match(new RegExp(`^[a-z0-9-]+\\.${extension}$`))
+function isSafeFilename (filename: string, extension?: string) {
+  const regex = extension
+    ? new RegExp(`^[a-z0-9-]+\\.${extension}$`)
+    : new RegExp(`^[a-z0-9-]+\\.[a-z0-9]{1,8}$`)
+
+  return typeof filename === 'string' && !!filename.match(regex)
 }
 
 function isSafePeerTubeFilenameWithoutExtension (filename: string) {
index 5f755d5bb110d118e447835cd24a7b35c7536bdd..934bd37c9516096c3380556bf9cffa7c014e77c9 100644 (file)
@@ -6,6 +6,7 @@ import {
   RunnerJobSuccessPayload,
   RunnerJobType,
   RunnerJobUpdatePayload,
+  VideoEditionTranscodingSuccess,
   VODAudioMergeTranscodingSuccess,
   VODHLSTranscodingSuccess,
   VODWebVideoTranscodingSuccess
@@ -23,7 +24,8 @@ function isRunnerJobSuccessPayloadValid (value: RunnerJobSuccessPayload, type: R
   return isRunnerJobVODWebVideoResultPayloadValid(value as VODWebVideoTranscodingSuccess, type, files) ||
     isRunnerJobVODHLSResultPayloadValid(value as VODHLSTranscodingSuccess, type, files) ||
     isRunnerJobVODAudioMergeResultPayloadValid(value as VODHLSTranscodingSuccess, type, files) ||
-    isRunnerJobLiveRTMPHLSResultPayloadValid(value as LiveRTMPHLSTranscodingSuccess, type)
+    isRunnerJobLiveRTMPHLSResultPayloadValid(value as LiveRTMPHLSTranscodingSuccess, type) ||
+    isRunnerJobVideoEditionResultPayloadValid(value as VideoEditionTranscodingSuccess, type, files)
 }
 
 // ---------------------------------------------------------------------------
@@ -35,6 +37,7 @@ function isRunnerJobProgressValid (value: string) {
 function isRunnerJobUpdatePayloadValid (value: RunnerJobUpdatePayload, type: RunnerJobType, files: UploadFilesForCheck) {
   return isRunnerJobVODWebVideoUpdatePayloadValid(value, type, files) ||
     isRunnerJobVODHLSUpdatePayloadValid(value, type, files) ||
+    isRunnerJobVideoEditionUpdatePayloadValid(value, type, files) ||
     isRunnerJobVODAudioMergeUpdatePayloadValid(value, type, files) ||
     isRunnerJobLiveRTMPHLSUpdatePayloadValid(value, type, files)
 }
@@ -102,6 +105,15 @@ function isRunnerJobLiveRTMPHLSResultPayloadValid (
   return type === 'live-rtmp-hls-transcoding' && (!value || (typeof value === 'object' && Object.keys(value).length === 0))
 }
 
+function isRunnerJobVideoEditionResultPayloadValid (
+  _value: VideoEditionTranscodingSuccess,
+  type: RunnerJobType,
+  files: UploadFilesForCheck
+) {
+  return type === 'video-edition-transcoding' &&
+    isFileValid({ files, field: 'payload[videoFile]', mimeTypeRegex: null, maxSize: null })
+}
+
 // ---------------------------------------------------------------------------
 
 function isRunnerJobVODWebVideoUpdatePayloadValid (
@@ -164,3 +176,12 @@ function isRunnerJobLiveRTMPHLSUpdatePayloadValid (
       )
     )
 }
+
+function isRunnerJobVideoEditionUpdatePayloadValid (
+  value: RunnerJobUpdatePayload,
+  type: RunnerJobType,
+  _files: UploadFilesForCheck
+) {
+  return type === 'video-edition-transcoding' &&
+    (!value || (typeof value === 'object' && Object.keys(value).length === 0))
+}
index 2361aa1eb285861ea0816d79f363c8108f262344..2f5a274e44428c36118f8de6038934fde563b185 100644 (file)
@@ -38,7 +38,7 @@ function checkMissedConfig () {
     'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
     'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
     'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
-    'video_studio.enabled',
+    'video_studio.enabled', 'video_studio.remote_runners.enabled',
     'remote_runners.stalled_jobs.vod', 'remote_runners.stalled_jobs.live',
     'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'import.videos.timeout',
     'import.video_channel_synchronization.enabled', 'import.video_channel_synchronization.max_per_user',
index f2d8f99b52a687ce76b73beae919fbee879c792b..9c270568953490480b99488fcaade2cd146df4b7 100644 (file)
@@ -423,7 +423,10 @@ const CONFIG = {
     }
   },
   VIDEO_STUDIO: {
-    get ENABLED () { return config.get<boolean>('video_studio.enabled') }
+    get ENABLED () { return config.get<boolean>('video_studio.enabled') },
+    REMOTE_RUNNERS: {
+      get ENABLED () { return config.get<boolean>('video_studio.remote_runners.enabled') }
+    }
   },
   IMPORT: {
     VIDEOS: {
index 279e7742167a3bd9e77aa66618c91637a994f90b..6a757a0ffc2c71c125ee063a2bf3988caa800221 100644 (file)
@@ -229,7 +229,8 @@ const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = {
   }
 }
 const JOB_PRIORITY = {
-  TRANSCODING: 100
+  TRANSCODING: 100,
+  VIDEO_STUDIO: 150
 }
 
 const JOB_REMOVAL_OPTIONS = {
index 5e8dd4f51a2a24d726df8c822d15f046e0a30fc4..df73caf72534c2651eb5be0f839d5ccc32f96235 100644 (file)
@@ -1,25 +1,18 @@
 import { Job } from 'bullmq'
-import { move, remove } from 'fs-extra'
+import { remove } from 'fs-extra'
 import { join } from 'path'
 import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
-import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
 import { CONFIG } from '@server/initializers/config'
-import { VIDEO_FILTERS } from '@server/initializers/constants'
-import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
-import { generateWebTorrentVideoFilename } from '@server/lib/paths'
-import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job'
 import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
 import { isAbleToUploadVideo } from '@server/lib/user'
-import { buildFileMetadata, removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file'
 import { VideoPathManager } from '@server/lib/video-path-manager'
-import { approximateIntroOutroAdditionalSize, safeCleanupStudioTMPFiles } from '@server/lib/video-studio'
+import { approximateIntroOutroAdditionalSize, onVideoEditionEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio'
 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 { buildUUID, getFileSize } from '@shared/extra-utils'
-import { FFmpegEdition, ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS } from '@shared/ffmpeg'
+import { MVideo, MVideoFullLight } from '@server/types/models'
+import { pick } from '@shared/core-utils'
+import { buildUUID } from '@shared/extra-utils'
+import { FFmpegEdition } from '@shared/ffmpeg'
 import {
   VideoStudioEditionPayload,
   VideoStudioTask,
@@ -46,7 +39,7 @@ async function processVideoStudioEdition (job: Job) {
     if (!video) {
       logger.info('Can\'t process job %d, video does not exist.', job.id, lTags)
 
-      await safeCleanupStudioTMPFiles(payload)
+      await safeCleanupStudioTMPFiles(payload.tasks)
       return undefined
     }
 
@@ -81,28 +74,9 @@ async function processVideoStudioEdition (job: Job) {
 
     logger.info('Video edition ended for video %s.', video.uuid, lTags)
 
-    const newFile = await buildNewFile(video, editionResultPath)
-
-    const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
-    await move(editionResultPath, outputPath)
-
-    await safeCleanupStudioTMPFiles(payload)
-
-    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)
-
-    const user = await UserModel.loadByVideoId(video.id)
-
-    await createOptimizeOrMergeAudioJobs({ video, videoFile: newFile, isNewVideo: false, user, videoFileAlreadyLocked: false })
+    await onVideoEditionEnded({ video, editionResultPath, tasks: payload.tasks })
   } catch (err) {
-    await safeCleanupStudioTMPFiles(payload)
+    await safeCleanupStudioTMPFiles(payload.tasks)
 
     throw err
   }
@@ -181,44 +155,15 @@ function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWater
     watermarkPath: task.options.file,
 
     videoFilters: {
-      watermarkSizeRatio: VIDEO_FILTERS.WATERMARK.SIZE_RATIO,
-      horitonzalMarginRatio: VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO,
-      verticalMarginRatio: VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO
+      watermarkSizeRatio: task.options.watermarkSizeRatio,
+      horitonzalMarginRatio: task.options.horitonzalMarginRatio,
+      verticalMarginRatio: task.options.verticalMarginRatio
     }
   })
 }
 
 // ---------------------------------------------------------------------------
 
-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) {
-  await removeHLSPlaylist(video)
-
-  for (const file of video.VideoFiles) {
-    if (file.id === webTorrentFileException.id) continue
-
-    await removeWebTorrentFile(video, file.id)
-  }
-}
-
 async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoStudioEditionPayload) {
   const user = await UserModel.loadByVideoId(video.id)
 
index 74b455107ea254fe95393421ac95a463dc1761cf..76fd1c5ac53a642f811dafec70eabf1ec8219697 100644 (file)
@@ -1,3 +1,4 @@
+import { throttle } from 'lodash'
 import { retryTransactionWrapper } from '@server/helpers/database-utils'
 import { logger, loggerTagsFactory } from '@server/helpers/logger'
 import { RUNNER_JOBS } from '@server/initializers/constants'
@@ -14,6 +15,8 @@ import {
   RunnerJobSuccessPayload,
   RunnerJobType,
   RunnerJobUpdatePayload,
+  RunnerJobVideoEditionTranscodingPayload,
+  RunnerJobVideoEditionTranscodingPrivatePayload,
   RunnerJobVODAudioMergeTranscodingPayload,
   RunnerJobVODAudioMergeTranscodingPrivatePayload,
   RunnerJobVODHLSTranscodingPayload,
@@ -21,7 +24,6 @@ import {
   RunnerJobVODWebVideoTranscodingPayload,
   RunnerJobVODWebVideoTranscodingPrivatePayload
 } from '@shared/models'
-import { throttle } from 'lodash'
 
 type CreateRunnerJobArg =
   {
@@ -43,6 +45,11 @@ type CreateRunnerJobArg =
     type: Extract<RunnerJobType, 'live-rtmp-hls-transcoding'>
     payload: RunnerJobLiveRTMPHLSTranscodingPayload
     privatePayload: RunnerJobLiveRTMPHLSTranscodingPrivatePayload
+  } |
+  {
+    type: Extract<RunnerJobType, 'video-edition-transcoding'>
+    payload: RunnerJobVideoEditionTranscodingPayload
+    privatePayload: RunnerJobVideoEditionTranscodingPrivatePayload
   }
 
 export abstract class AbstractJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> {
@@ -62,6 +69,8 @@ export abstract class AbstractJobHandler <C, U extends RunnerJobUpdatePayload, S
   }): Promise<MRunnerJob> {
     const { priority, dependsOnRunnerJob } = options
 
+    logger.debug('Creating runner job', { options, ...this.lTags(options.type) })
+
     const runnerJob = new RunnerJobModel({
       ...pick(options, [ 'type', 'payload', 'privatePayload' ]),
 
index 517645848be4c3a8c15edb0afb7d92faa9f6291f..a910ae383b1b06790cbd3a84359e6de84d9963d8 100644 (file)
@@ -4,27 +4,19 @@ import { logger } from '@server/helpers/logger'
 import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
 import { VideoJobInfoModel } from '@server/models/video/video-job-info'
 import { MRunnerJob } from '@server/types/models/runners'
-import {
-  LiveRTMPHLSTranscodingUpdatePayload,
-  RunnerJobSuccessPayload,
-  RunnerJobUpdatePayload,
-  RunnerJobVODPrivatePayload
-} from '@shared/models'
+import { RunnerJobSuccessPayload, RunnerJobUpdatePayload, RunnerJobVODPrivatePayload } from '@shared/models'
 import { AbstractJobHandler } from './abstract-job-handler'
 import { loadTranscodingRunnerVideo } from './shared'
 
 // eslint-disable-next-line max-len
 export abstract class AbstractVODTranscodingJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> extends AbstractJobHandler<C, U, S> {
 
-  // ---------------------------------------------------------------------------
-
   protected isAbortSupported () {
     return true
   }
 
   protected specificUpdate (_options: {
     runnerJob: MRunnerJob
-    updatePayload?: LiveRTMPHLSTranscodingUpdatePayload
   }) {
     // empty
   }
index 0fca72b9aed3015e4950bd8e25dd5f34117ba072..a40cee86504619f55745cee8d3bfd1cef093b593 100644 (file)
@@ -1,6 +1,7 @@
 export * from './abstract-job-handler'
 export * from './live-rtmp-hls-transcoding-job-handler'
+export * from './runner-job-handlers'
+export * from './video-edition-transcoding-job-handler'
 export * from './vod-audio-merge-transcoding-job-handler'
 export * from './vod-hls-transcoding-job-handler'
 export * from './vod-web-video-transcoding-job-handler'
-export * from './runner-job-handlers'
index c3d0e427d4e124019085cf5b3a14107d263d89c1..48a70d891cf6d54aa7abcae312dd1a6c899a07e0 100644 (file)
@@ -70,7 +70,7 @@ export class LiveRTMPHLSTranscodingJobHandler extends AbstractJobHandler<CreateO
 
   // ---------------------------------------------------------------------------
 
-  async specificUpdate (options: {
+  protected async specificUpdate (options: {
     runnerJob: MRunnerJob
     updatePayload: LiveRTMPHLSTranscodingUpdatePayload
   }) {
index 7bad1bc777fdff1ee31deab90fd8668a85277b30..4ea6684ea93f9616065f26f2f242b156f16336c4 100644 (file)
@@ -2,6 +2,7 @@ import { MRunnerJob } from '@server/types/models/runners'
 import { RunnerJobSuccessPayload, RunnerJobType, RunnerJobUpdatePayload } from '@shared/models'
 import { AbstractJobHandler } from './abstract-job-handler'
 import { LiveRTMPHLSTranscodingJobHandler } from './live-rtmp-hls-transcoding-job-handler'
+import { VideoEditionTranscodingJobHandler } from './video-edition-transcoding-job-handler'
 import { VODAudioMergeTranscodingJobHandler } from './vod-audio-merge-transcoding-job-handler'
 import { VODHLSTranscodingJobHandler } from './vod-hls-transcoding-job-handler'
 import { VODWebVideoTranscodingJobHandler } from './vod-web-video-transcoding-job-handler'
@@ -10,7 +11,8 @@ const processors: Record<RunnerJobType, new() => AbstractJobHandler<unknown, Run
   'vod-web-video-transcoding': VODWebVideoTranscodingJobHandler,
   'vod-hls-transcoding': VODHLSTranscodingJobHandler,
   'vod-audio-merge-transcoding': VODAudioMergeTranscodingJobHandler,
-  'live-rtmp-hls-transcoding': LiveRTMPHLSTranscodingJobHandler
+  'live-rtmp-hls-transcoding': LiveRTMPHLSTranscodingJobHandler,
+  'video-edition-transcoding': VideoEditionTranscodingJobHandler
 }
 
 export function getRunnerJobHandlerClass (job: MRunnerJob) {
diff --git a/server/lib/runners/job-handlers/video-edition-transcoding-job-handler.ts b/server/lib/runners/job-handlers/video-edition-transcoding-job-handler.ts
new file mode 100644 (file)
index 0000000..39a755c
--- /dev/null
@@ -0,0 +1,157 @@
+
+import { basename } from 'path'
+import { logger } from '@server/helpers/logger'
+import { onVideoEditionEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio'
+import { MVideo } from '@server/types/models'
+import { MRunnerJob } from '@server/types/models/runners'
+import { buildUUID } from '@shared/extra-utils'
+import {
+  isVideoStudioTaskIntro,
+  isVideoStudioTaskOutro,
+  isVideoStudioTaskWatermark,
+  RunnerJobState,
+  RunnerJobUpdatePayload,
+  RunnerJobVideoEditionTranscodingPayload,
+  RunnerJobVideoEditionTranscodingPrivatePayload,
+  VideoEditionTranscodingSuccess,
+  VideoState,
+  VideoStudioTaskPayload
+} from '@shared/models'
+import { generateRunnerEditionTranscodingVideoInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls'
+import { AbstractJobHandler } from './abstract-job-handler'
+import { loadTranscodingRunnerVideo } from './shared'
+
+type CreateOptions = {
+  video: MVideo
+  tasks: VideoStudioTaskPayload[]
+  priority: number
+}
+
+// eslint-disable-next-line max-len
+export class VideoEditionTranscodingJobHandler extends AbstractJobHandler<CreateOptions, RunnerJobUpdatePayload, VideoEditionTranscodingSuccess> {
+
+  async create (options: CreateOptions) {
+    const { video, priority, tasks } = options
+
+    const jobUUID = buildUUID()
+    const payload: RunnerJobVideoEditionTranscodingPayload = {
+      input: {
+        videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
+      },
+      tasks: tasks.map(t => {
+        if (isVideoStudioTaskIntro(t) || isVideoStudioTaskOutro(t)) {
+          return {
+            ...t,
+
+            options: {
+              ...t.options,
+
+              file: generateRunnerEditionTranscodingVideoInputFileUrl(jobUUID, video.uuid, basename(t.options.file))
+            }
+          }
+        }
+
+        if (isVideoStudioTaskWatermark(t)) {
+          return {
+            ...t,
+
+            options: {
+              ...t.options,
+
+              file: generateRunnerEditionTranscodingVideoInputFileUrl(jobUUID, video.uuid, basename(t.options.file))
+            }
+          }
+        }
+
+        return t
+      })
+    }
+
+    const privatePayload: RunnerJobVideoEditionTranscodingPrivatePayload = {
+      videoUUID: video.uuid,
+      originalTasks: tasks
+    }
+
+    const job = await this.createRunnerJob({
+      type: 'video-edition-transcoding',
+      jobUUID,
+      payload,
+      privatePayload,
+      priority
+    })
+
+    return job
+  }
+
+  // ---------------------------------------------------------------------------
+
+  protected isAbortSupported () {
+    return true
+  }
+
+  protected specificUpdate (_options: {
+    runnerJob: MRunnerJob
+  }) {
+    // empty
+  }
+
+  protected specificAbort (_options: {
+    runnerJob: MRunnerJob
+  }) {
+    // empty
+  }
+
+  protected async specificComplete (options: {
+    runnerJob: MRunnerJob
+    resultPayload: VideoEditionTranscodingSuccess
+  }) {
+    const { runnerJob, resultPayload } = options
+    const privatePayload = runnerJob.privatePayload as RunnerJobVideoEditionTranscodingPrivatePayload
+
+    const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
+    if (!video) {
+      await safeCleanupStudioTMPFiles(privatePayload.originalTasks)
+
+    }
+
+    const videoFilePath = resultPayload.videoFile as string
+
+    await onVideoEditionEnded({ video, editionResultPath: videoFilePath, tasks: privatePayload.originalTasks })
+
+    logger.info(
+      'Runner video edition transcoding job %s for %s ended.',
+      runnerJob.uuid, video.uuid, this.lTags(video.uuid, runnerJob.uuid)
+    )
+  }
+
+  protected specificError (options: {
+    runnerJob: MRunnerJob
+    nextState: RunnerJobState
+  }) {
+    if (options.nextState === RunnerJobState.ERRORED) {
+      return this.specificErrorOrCancel(options)
+    }
+
+    return Promise.resolve()
+  }
+
+  protected specificCancel (options: {
+    runnerJob: MRunnerJob
+  }) {
+    return this.specificErrorOrCancel(options)
+  }
+
+  private async specificErrorOrCancel (options: {
+    runnerJob: MRunnerJob
+  }) {
+    const { runnerJob } = options
+
+    const payload = runnerJob.privatePayload as RunnerJobVideoEditionTranscodingPrivatePayload
+    await safeCleanupStudioTMPFiles(payload.originalTasks)
+
+    const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
+    if (!video) return
+
+    return video.setNewState(VideoState.PUBLISHED, false, undefined)
+  }
+}
index a7b33f87e7132b37fe586a8294eb3f5681d29359..5f247d7929db87b08b29e307c61502955ff05e75 100644 (file)
@@ -64,7 +64,7 @@ export class VODAudioMergeTranscodingJobHandler extends AbstractVODTranscodingJo
 
   // ---------------------------------------------------------------------------
 
-  async specificComplete (options: {
+  protected async specificComplete (options: {
     runnerJob: MRunnerJob
     resultPayload: VODAudioMergeTranscodingSuccess
   }) {
index 02566b9d5e9117da84552bcf116c2c13e9119382..cc94bcbda072da9583541911e85a7a030a210bfb 100644 (file)
@@ -71,7 +71,7 @@ export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandle
 
   // ---------------------------------------------------------------------------
 
-  async specificComplete (options: {
+  protected async specificComplete (options: {
     runnerJob: MRunnerJob
     resultPayload: VODHLSTranscodingSuccess
   }) {
index 57761a7a10cccccf7a7f2fb207148c5ef682aad9..663d3306e26d44c7b4133d6d70000f06b7c61aa0 100644 (file)
@@ -62,7 +62,7 @@ export class VODWebVideoTranscodingJobHandler extends AbstractVODTranscodingJobH
 
   // ---------------------------------------------------------------------------
 
-  async specificComplete (options: {
+  protected async specificComplete (options: {
     runnerJob: MRunnerJob
     resultPayload: VODWebVideoTranscodingSuccess
   }) {
index 329fb11708478ee348a9b24e2a0e1ca20349f835..a27060b33f38f147cd394d829017667f98b401f2 100644 (file)
@@ -7,3 +7,7 @@ export function generateRunnerTranscodingVideoInputFileUrl (jobUUID: string, vid
 export function generateRunnerTranscodingVideoPreviewFileUrl (jobUUID: string, videoUUID: string) {
   return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/previews/max-quality'
 }
+
+export function generateRunnerEditionTranscodingVideoInputFileUrl (jobUUID: string, videoUUID: string, filename: string) {
+  return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/studio/task-files/' + filename
+}
index ba791636330eae65312bd2b692c3d2a6b0901894..924adb33715e3307f31cbf2ccd0896e265cd8ec8 100644 (file)
@@ -166,7 +166,10 @@ class ServerConfigManager {
         }
       },
       videoStudio: {
-        enabled: CONFIG.VIDEO_STUDIO.ENABLED
+        enabled: CONFIG.VIDEO_STUDIO.ENABLED,
+        remoteRunners: {
+          enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
+        }
       },
       import: {
         videos: {
index 576e786d5122a8fa2a0f3c9f08e62b39fce825ef..80dc05bfb62c7575400cc2b2bcd314659863c34e 100644 (file)
@@ -1,6 +1,4 @@
 
-import { JOB_PRIORITY } from '@server/initializers/constants'
-import { VideoModel } from '@server/models/video/video'
 import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
 
 export abstract class AbstractJobBuilder {
@@ -20,20 +18,4 @@ export abstract class AbstractJobBuilder {
     isNewVideo: boolean
     user: MUserId | null
   }): Promise<any>
-
-  protected async getTranscodingJobPriority (options: {
-    user: MUserId
-    fallback: number
-  }) {
-    const { user, fallback } = options
-
-    if (!user) return fallback
-
-    const now = new Date()
-    const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
-
-    const videoUploadedByUser = await VideoModel.countVideosUploadedByUserSince(user.id, lastWeek)
-
-    return JOB_PRIORITY.TRANSCODING + videoUploadedByUser
-  }
 }
index 5a9c93ee5271486436c0433fe1d05d5ba8d35611..29ee2ca61fe28afd6ae887dfab910f290c89b8df 100644 (file)
@@ -16,6 +16,7 @@ import {
   OptimizeTranscodingPayload,
   VideoTranscodingPayload
 } from '@shared/models'
+import { getTranscodingJobPriority } from '../../transcoding-priority'
 import { canDoQuickTranscode } from '../../transcoding-quick-transcode'
 import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
 import { AbstractJobBuilder } from './abstract-job-builder'
@@ -178,7 +179,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
 
     return {
       type: 'video-transcoding' as 'video-transcoding',
-      priority: await this.getTranscodingJobPriority({ user, fallback: undefined }),
+      priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: undefined }),
       payload
     }
   }
index 274dce21b74c9163ff00223a5659fba6d39f2138..90b035402053fbea52c5a64c65183b21865229d2 100644 (file)
@@ -8,6 +8,7 @@ import { VideoPathManager } from '@server/lib/video-path-manager'
 import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
 import { MRunnerJob } from '@server/types/models/runners'
 import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
+import { getTranscodingJobPriority } from '../../transcoding-priority'
 import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
 import { AbstractJobBuilder } from './abstract-job-builder'
 
@@ -49,7 +50,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
           : resolution
 
         const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
-        const priority = await this.getTranscodingJobPriority({ user, fallback: 0 })
+        const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
 
         const mainRunnerJob = videoFile.isAudio()
           ? await new VODAudioMergeTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
@@ -63,7 +64,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
             fps,
             isNewVideo,
             dependsOnRunnerJob: mainRunnerJob,
-            priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
+            priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
           })
         }
 
@@ -96,7 +97,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
     const maxResolution = Math.max(...resolutions)
     const { fps: inputFPS } = await video.probeMaxQualityFile()
     const maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution })
-    const priority = await this.getTranscodingJobPriority({ user, fallback: 0 })
+    const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
 
     const childrenResolutions = resolutions.filter(r => r !== maxResolution)
 
@@ -121,7 +122,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
           isNewVideo,
           deleteWebVideoFiles: false,
           dependsOnRunnerJob,
-          priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
+          priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
         })
         continue
       }
@@ -133,7 +134,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
           fps,
           isNewVideo,
           dependsOnRunnerJob,
-          priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
+          priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
         })
         continue
       }
@@ -172,7 +173,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
           fps,
           isNewVideo,
           dependsOnRunnerJob: mainRunnerJob,
-          priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
+          priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
         })
       }
 
@@ -184,7 +185,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
           isNewVideo,
           deleteWebVideoFiles: false,
           dependsOnRunnerJob: mainRunnerJob,
-          priority: await this.getTranscodingJobPriority({ user, fallback: 0 })
+          priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
         })
       }
     }
diff --git a/server/lib/transcoding/transcoding-priority.ts b/server/lib/transcoding/transcoding-priority.ts
new file mode 100644 (file)
index 0000000..82ab6f2
--- /dev/null
@@ -0,0 +1,24 @@
+import { JOB_PRIORITY } from '@server/initializers/constants'
+import { VideoModel } from '@server/models/video/video'
+import { MUserId } from '@server/types/models'
+
+export async function getTranscodingJobPriority (options: {
+  user: MUserId
+  fallback: number
+  type: 'vod' | 'studio'
+}) {
+  const { user, fallback, type } = options
+
+  if (!user) return fallback
+
+  const now = new Date()
+  const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
+
+  const videoUploadedByUser = await VideoModel.countVideosUploadedByUserSince(user.id, lastWeek)
+
+  const base = type === 'vod'
+    ? JOB_PRIORITY.TRANSCODING
+    : JOB_PRIORITY.VIDEO_STUDIO
+
+  return base + videoUploadedByUser
+}
index beda326a0824e8d7b8f0d1625961dad3013076c0..2c993faeb2d07dfccb066a5997ea8431238ec72e 100644 (file)
@@ -1,19 +1,38 @@
-import { logger } from '@server/helpers/logger'
-import { MVideoFullLight } from '@server/types/models'
+import { move, remove } from 'fs-extra'
+import { join } from 'path'
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
+import { CONFIG } from '@server/initializers/config'
+import { UserModel } from '@server/models/user/user'
+import { MUser, MVideo, MVideoFile, MVideoFullLight, MVideoWithAllFiles } from '@server/types/models'
 import { getVideoStreamDuration } from '@shared/ffmpeg'
-import { VideoStudioEditionPayload, VideoStudioTask } from '@shared/models'
-import { remove } from 'fs-extra'
+import { VideoStudioEditionPayload, VideoStudioTask, VideoStudioTaskPayload } from '@shared/models'
+import { federateVideoIfNeeded } from './activitypub/videos'
+import { JobQueue } from './job-queue'
+import { VideoEditionTranscodingJobHandler } from './runners'
+import { createOptimizeOrMergeAudioJobs } from './transcoding/create-transcoding-job'
+import { getTranscodingJobPriority } from './transcoding/transcoding-priority'
+import { buildNewFile, removeHLSPlaylist, removeWebTorrentFile } from './video-file'
+import { VideoPathManager } from './video-path-manager'
 
-function buildTaskFileFieldname (indice: number, fieldName = 'file') {
+const lTags = loggerTagsFactory('video-edition')
+
+export function buildTaskFileFieldname (indice: number, fieldName = 'file') {
   return `tasks[${indice}][options][${fieldName}]`
 }
 
-function getTaskFileFromReq (files: Express.Multer.File[], indice: number, fieldName = 'file') {
+export function getTaskFileFromReq (files: Express.Multer.File[], indice: number, fieldName = 'file') {
   return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
 }
 
-async function safeCleanupStudioTMPFiles (payload: VideoStudioEditionPayload) {
-  for (const task of payload.tasks) {
+export function getStudioTaskFilePath (filename: string) {
+  return join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, filename)
+}
+
+export async function safeCleanupStudioTMPFiles (tasks: VideoStudioTaskPayload[]) {
+  logger.info('Removing 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)
@@ -26,7 +45,13 @@ async function safeCleanupStudioTMPFiles (payload: VideoStudioEditionPayload) {
   }
 }
 
-async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoStudioTask[], fileFinder: (i: number) => string) {
+// ---------------------------------------------------------------------------
+
+export async function approximateIntroOutroAdditionalSize (
+  video: MVideoFullLight,
+  tasks: VideoStudioTask[],
+  fileFinder: (i: number) => string
+) {
   let additionalDuration = 0
 
   for (let i = 0; i < tasks.length; i++) {
@@ -41,9 +66,65 @@ async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, task
   return (video.getMaxQualityFile().size / video.duration) * additionalDuration
 }
 
-export {
-  approximateIntroOutroAdditionalSize,
-  buildTaskFileFieldname,
-  getTaskFileFromReq,
-  safeCleanupStudioTMPFiles
+// ---------------------------------------------------------------------------
+
+export async function createVideoStudioJob (options: {
+  video: MVideo
+  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 VideoEditionTranscodingJobHandler().create({ video, tasks: payload.tasks, priority })
+    return
+  }
+
+  await JobQueue.Instance.createJob({ type: 'video-studio-edition', payload, priority })
+}
+
+export async function onVideoEditionEnded (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)
+  await video.save()
+
+  await federateVideoIfNeeded(video, false, undefined)
+
+  const user = await UserModel.loadByVideoId(video.id)
+
+  await createOptimizeOrMergeAudioJobs({ video, videoFile: newFile, isNewVideo: false, user, videoFileAlreadyLocked: false })
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
+  await removeHLSPlaylist(video)
+
+  for (const file of video.VideoFiles) {
+    if (file.id === webTorrentFileException.id) continue
+
+    await removeWebTorrentFile(video, file.id)
+  }
 }
index b3e7e5011ef987f81d6fe77d1b1168cac81f6bcd..a0074cb248147de5c73a1d65fcca4ea28f4504ef 100644 (file)
@@ -62,6 +62,7 @@ const customConfigUpdateValidator = [
   body('transcoding.hls.enabled').isBoolean(),
 
   body('videoStudio.enabled').isBoolean(),
+  body('videoStudio.remoteRunners.enabled').isBoolean(),
 
   body('import.videos.concurrency').isInt({ min: 0 }),
   body('import.videos.http.enabled').isBoolean(),
index 56afa39aa1980d82bf741b0d547f3d4e7734e597..e5afff0e5bd065f3506361303bf8da1230c8e3db 100644 (file)
@@ -1,5 +1,8 @@
 import express from 'express'
-import { HttpStatusCode } from '@shared/models'
+import { param } from 'express-validator'
+import { basename } from 'path'
+import { isSafeFilename } from '@server/helpers/custom-validators/misc'
+import { hasVideoStudioTaskFile, HttpStatusCode, RunnerJobVideoEditionTranscodingPayload } from '@shared/models'
 import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
 
 const tags = [ 'runner' ]
@@ -25,3 +28,33 @@ export const runnerJobGetVideoTranscodingFileValidator = [
     return next()
   }
 ]
+
+export const runnerJobGetVideoStudioTaskFileValidator = [
+  param('filename').custom(v => isSafeFilename(v)),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    const filename = req.params.filename
+
+    const payload = res.locals.runnerJob.payload as RunnerJobVideoEditionTranscodingPayload
+
+    const found = Array.isArray(payload?.tasks) && payload.tasks.some(t => {
+      if (hasVideoStudioTaskFile(t)) {
+        return basename(t.options.file) === filename
+      }
+
+      return false
+    })
+
+    if (!found) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: 'File is not associated to this edition task',
+        tags: [ ...tags, res.locals.videoAll.uuid ]
+      })
+    }
+
+    return next()
+  }
+]
index 8cb87e9467843e282331d72f5bd6e7054916d38a..de956a1ca01fb49359efb5d9db68537bb493ffcb 100644 (file)
@@ -91,6 +91,28 @@ export const successRunnerJobValidator = [
   }
 ]
 
+export const cancelRunnerJobValidator = [
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    const runnerJob = res.locals.runnerJob
+
+    const allowedStates = new Set<RunnerJobState>([
+      RunnerJobState.PENDING,
+      RunnerJobState.PROCESSING,
+      RunnerJobState.WAITING_FOR_PARENT_JOB
+    ])
+
+    if (allowedStates.has(runnerJob.state) !== true) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: 'Cannot cancel this job that is not in "pending", "processing" or "waiting for parent job" state',
+        tags
+      })
+    }
+
+    return next()
+  }
+]
+
 export const runnerJobGetValidator = [
   param('jobUUID').custom(isUUIDValid),
 
index c5cda203e8d2a3e2872e12e041aeeebfb22fa354..472cad182c6b744a94181fbeebd15536f3792279 100644 (file)
@@ -162,7 +162,10 @@ describe('Test config API validators', function () {
       }
     },
     videoStudio: {
-      enabled: true
+      enabled: true,
+      remoteRunners: {
+        enabled: true
+      }
     },
     import: {
       videos: {
index 4da6fd91d1f1f23a1766690429c9c7a3a03edea0..90a3013929c68bc357b65b7fead630fa5ff033df 100644 (file)
@@ -1,6 +1,17 @@
+import { basename } from 'path'
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@server/tests/shared'
-import { HttpStatusCode, RunnerJob, RunnerJobState, RunnerJobSuccessPayload, RunnerJobUpdatePayload, VideoPrivacy } from '@shared/models'
+import {
+  HttpStatusCode,
+  isVideoStudioTaskIntro,
+  RunnerJob,
+  RunnerJobState,
+  RunnerJobSuccessPayload,
+  RunnerJobUpdatePayload,
+  RunnerJobVideoEditionTranscodingPayload,
+  VideoPrivacy,
+  VideoStudioTaskIntro
+} from '@shared/models'
 import {
   cleanupTests,
   createSingleServer,
@@ -10,6 +21,7 @@ import {
   setAccessTokensToServers,
   setDefaultVideoChannel,
   stopFfmpeg,
+  VideoStudioCommand,
   waitJobs
 } from '@shared/server-commands'
 
@@ -53,7 +65,10 @@ describe('Test managing runners', function () {
     registrationTokenId = data[0].id
 
     await server.config.enableTranscoding(true, true)
+    await server.config.enableStudio()
     await server.config.enableRemoteTranscoding()
+    await server.config.enableRemoteStudio()
+
     runnerToken = await server.runners.autoRegisterRunner()
     runnerToken2 = await server.runners.autoRegisterRunner()
 
@@ -249,6 +264,10 @@ describe('Test managing runners', function () {
         await server.runnerJobs.cancelByAdmin({ jobUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
       })
 
+      it('Should fail with an already cancelled job', async function () {
+        await server.runnerJobs.cancelByAdmin({ jobUUID: cancelledJobUUID, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      })
+
       it('Should succeed with the correct params', async function () {
         await server.runnerJobs.cancelByAdmin({ jobUUID })
       })
@@ -296,9 +315,13 @@ describe('Test managing runners', function () {
 
     let pendingUUID: string
 
+    let videoStudioUUID: string
+    let studioFile: string
+
     let liveAcceptedJob: RunnerJob & { jobToken: string }
+    let studioAcceptedJob: RunnerJob & { jobToken: string }
 
-    async function fetchFiles (options: {
+    async function fetchVideoInputFiles (options: {
       jobUUID: string
       videoUUID: string
       runnerToken: string
@@ -315,6 +338,21 @@ describe('Test managing runners', function () {
       }
     }
 
+    async function fetchStudioFiles (options: {
+      jobUUID: string
+      videoUUID: string
+      runnerToken: string
+      jobToken: string
+      studioFile?: string
+      expectedStatus: HttpStatusCode
+    }) {
+      const { jobUUID, expectedStatus, videoUUID, runnerToken, jobToken, studioFile } = options
+
+      const path = `/api/v1/runners/jobs/${jobUUID}/files/videos/${videoUUID}/studio/task-files/${studioFile}`
+
+      await makePostBodyRequest({ url: server.url, path, fields: { runnerToken, jobToken }, expectedStatus })
+    }
+
     before(async function () {
       this.timeout(120000)
 
@@ -352,6 +390,28 @@ describe('Test managing runners', function () {
         pendingUUID = availableJobs[0].uuid
       }
 
+      {
+        await server.config.disableTranscoding()
+
+        const { uuid } = await server.videos.quickUpload({ name: 'video studio' })
+        videoStudioUUID = uuid
+
+        await server.config.enableTranscoding(true, true)
+        await server.config.enableStudio()
+
+        await server.videoStudio.createEditionTasks({
+          videoId: videoStudioUUID,
+          tasks: VideoStudioCommand.getComplexTask()
+        })
+
+        const { job } = await server.runnerJobs.autoAccept({ runnerToken, type: 'video-edition-transcoding' })
+        studioAcceptedJob = job
+
+        const tasks = (job.payload as RunnerJobVideoEditionTranscodingPayload).tasks
+        const fileUrl = (tasks.find(t => isVideoStudioTaskIntro(t)) as VideoStudioTaskIntro).options.file as string
+        studioFile = basename(fileUrl)
+      }
+
       {
         await server.config.enableLive({
           allowReplay: false,
@@ -381,8 +441,6 @@ describe('Test managing runners', function () {
         jobToken: string
         expectedStatus: HttpStatusCode
       }) {
-        await fetchFiles({ ...options, videoUUID })
-
         await server.runnerJobs.abort({ ...options, reason: 'reason' })
         await server.runnerJobs.update({ ...options })
         await server.runnerJobs.error({ ...options, message: 'message' })
@@ -390,39 +448,95 @@ describe('Test managing runners', function () {
       }
 
       it('Should fail with an invalid job uuid', async function () {
-        await testEndpoints({ jobUUID: 'a', runnerToken, jobToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+        const options = { jobUUID: 'a', runnerToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+
+        await testEndpoints({ ...options, jobToken })
+        await fetchVideoInputFiles({ ...options, videoUUID, jobToken })
+        await fetchStudioFiles({ ...options, videoUUID, jobToken: studioAcceptedJob.jobToken, studioFile })
       })
 
       it('Should fail with an unknown job uuid', async function () {
-        const jobUUID = badUUID
-        await testEndpoints({ jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+        const options = { jobUUID: badUUID, runnerToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+
+        await testEndpoints({ ...options, jobToken })
+        await fetchVideoInputFiles({ ...options, videoUUID, jobToken })
+        await fetchStudioFiles({ ...options, jobToken: studioAcceptedJob.jobToken, videoUUID, studioFile })
       })
 
       it('Should fail with an invalid runner token', async function () {
-        await testEndpoints({ jobUUID, runnerToken: '', jobToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+        const options = { runnerToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+
+        await testEndpoints({ ...options, jobUUID, jobToken })
+        await fetchVideoInputFiles({ ...options, jobUUID, videoUUID, jobToken })
+        await fetchStudioFiles({
+          ...options,
+          jobToken: studioAcceptedJob.jobToken,
+          jobUUID: studioAcceptedJob.uuid,
+          videoUUID: videoStudioUUID,
+          studioFile
+        })
       })
 
       it('Should fail with an unknown runner token', async function () {
-        const runnerToken = badUUID
-        await testEndpoints({ jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+        const options = { runnerToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+
+        await testEndpoints({ ...options, jobUUID, jobToken })
+        await fetchVideoInputFiles({ ...options, jobUUID, videoUUID, jobToken })
+        await fetchStudioFiles({
+          ...options,
+          jobToken: studioAcceptedJob.jobToken,
+          jobUUID: studioAcceptedJob.uuid,
+          videoUUID: videoStudioUUID,
+          studioFile
+        })
       })
 
       it('Should fail with an invalid job token job uuid', async function () {
-        await testEndpoints({ jobUUID, runnerToken, jobToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+        const options = { runnerToken, jobToken: '', expectedStatus: HttpStatusCode.BAD_REQUEST_400 }
+
+        await testEndpoints({ ...options, jobUUID })
+        await fetchVideoInputFiles({ ...options, jobUUID, videoUUID })
+        await fetchStudioFiles({ ...options, jobUUID: studioAcceptedJob.uuid, videoUUID: videoStudioUUID, studioFile })
       })
 
       it('Should fail with an unknown job token job uuid', async function () {
-        const jobToken = badUUID
-        await testEndpoints({ jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+        const options = { runnerToken, jobToken: badUUID, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+
+        await testEndpoints({ ...options, jobUUID })
+        await fetchVideoInputFiles({ ...options, jobUUID, videoUUID })
+        await fetchStudioFiles({ ...options, jobUUID: studioAcceptedJob.uuid, videoUUID: videoStudioUUID, studioFile })
       })
 
       it('Should fail with a runner token not associated to this job', async function () {
-        await testEndpoints({ jobUUID, runnerToken: runnerToken2, jobToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+        const options = { runnerToken: runnerToken2, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+
+        await testEndpoints({ ...options, jobUUID, jobToken })
+        await fetchVideoInputFiles({ ...options, jobUUID, videoUUID, jobToken })
+        await fetchStudioFiles({
+          ...options,
+          jobToken: studioAcceptedJob.jobToken,
+          jobUUID: studioAcceptedJob.uuid,
+          videoUUID: videoStudioUUID,
+          studioFile
+        })
       })
 
       it('Should fail with a job uuid not associated to the job token', async function () {
-        await testEndpoints({ jobUUID: jobUUID2, runnerToken, jobToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
-        await testEndpoints({ jobUUID, runnerToken, jobToken: jobToken2, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+        {
+          const options = { jobUUID: jobUUID2, runnerToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+
+          await testEndpoints({ ...options, jobToken })
+          await fetchVideoInputFiles({ ...options, jobToken, videoUUID })
+          await fetchStudioFiles({ ...options, jobToken: studioAcceptedJob.jobToken, videoUUID: videoStudioUUID, studioFile })
+        }
+
+        {
+          const options = { runnerToken, jobToken: jobToken2, expectedStatus: HttpStatusCode.NOT_FOUND_404 }
+
+          await testEndpoints({ ...options, jobUUID })
+          await fetchVideoInputFiles({ ...options, jobUUID, videoUUID })
+          await fetchStudioFiles({ ...options, jobUUID: studioAcceptedJob.uuid, videoUUID: videoStudioUUID, studioFile })
+        }
       })
     })
 
@@ -670,27 +784,82 @@ describe('Test managing runners', function () {
           })
         })
       })
+
+      describe('Video studio', function () {
+
+        it('Should fail with an invalid video edition transcoding payload', async function () {
+          await server.runnerJobs.success({
+            jobUUID: studioAcceptedJob.uuid,
+            jobToken: studioAcceptedJob.jobToken,
+            payload: { hello: 'video_short.mp4' } as any,
+            runnerToken,
+            expectedStatus: HttpStatusCode.BAD_REQUEST_400
+          })
+        })
+      })
     })
 
     describe('Job files', function () {
 
-      describe('Video files', function () {
+      describe('Check video param for common job file routes', function () {
+
+        async function fetchFiles (options: {
+          videoUUID?: string
+          expectedStatus: HttpStatusCode
+        }) {
+          await fetchVideoInputFiles({ videoUUID, ...options, jobToken, jobUUID, runnerToken })
+
+          await fetchStudioFiles({
+            videoUUID: videoStudioUUID,
+
+            ...options,
+
+            jobToken: studioAcceptedJob.jobToken,
+            jobUUID: studioAcceptedJob.uuid,
+            runnerToken,
+            studioFile
+          })
+        }
 
         it('Should fail with an invalid video id', async function () {
-          await fetchFiles({ videoUUID: 'a', jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+          await fetchFiles({
+            videoUUID: 'a',
+            expectedStatus: HttpStatusCode.BAD_REQUEST_400
+          })
         })
 
         it('Should fail with an unknown video id', async function () {
           const videoUUID = '910ec12a-d9e6-458b-a274-0abb655f9464'
-          await fetchFiles({ videoUUID, jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+
+          await fetchFiles({
+            videoUUID,
+            expectedStatus: HttpStatusCode.NOT_FOUND_404
+          })
         })
 
         it('Should fail with a video id not associated to this job', async function () {
-          await fetchFiles({ videoUUID: videoUUID2, jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+          await fetchFiles({
+            videoUUID: videoUUID2,
+            expectedStatus: HttpStatusCode.FORBIDDEN_403
+          })
         })
 
         it('Should succeed with the correct params', async function () {
-          await fetchFiles({ videoUUID, jobUUID, runnerToken, jobToken, expectedStatus: HttpStatusCode.OK_200 })
+          await fetchFiles({ expectedStatus: HttpStatusCode.OK_200 })
+        })
+      })
+
+      describe('Video edition tasks file routes', function () {
+
+        it('Should fail with an invalid studio filename', async function () {
+          await fetchStudioFiles({
+            videoUUID: videoStudioUUID,
+            jobUUID: studioAcceptedJob.uuid,
+            runnerToken,
+            jobToken: studioAcceptedJob.jobToken,
+            studioFile: 'toto',
+            expectedStatus: HttpStatusCode.BAD_REQUEST_400
+          })
         })
       })
     })
index 7f33ec8dd14473c5f46041f35796f8fa4bcb17b7..642a3a96d1e4a056a7694cd388e4203348460132 100644 (file)
@@ -1,4 +1,5 @@
 export * from './runner-common'
 export * from './runner-live-transcoding'
 export * from './runner-socket'
+export * from './runner-studio-transcoding'
 export * from './runner-vod-transcoding'
index a2204753bde3ab7511803c9a4c342c371004cdf2..5540241908fc8fa0d9998457172ef0c77c8abb1a 100644 (file)
@@ -2,7 +2,15 @@
 
 import { expect } from 'chai'
 import { wait } from '@shared/core-utils'
-import { HttpStatusCode, Runner, RunnerJob, RunnerJobAdmin, RunnerJobState, RunnerRegistrationToken } from '@shared/models'
+import {
+  HttpStatusCode,
+  Runner,
+  RunnerJob,
+  RunnerJobAdmin,
+  RunnerJobState,
+  RunnerJobVODWebVideoTranscodingPayload,
+  RunnerRegistrationToken
+} from '@shared/models'
 import {
   cleanupTests,
   createSingleServer,
@@ -349,7 +357,7 @@ describe('Test runner common actions', function () {
         for (const job of availableJobs) {
           expect(job.uuid).to.exist
           expect(job.payload.input).to.exist
-          expect(job.payload.output).to.exist
+          expect((job.payload as RunnerJobVODWebVideoTranscodingPayload).output).to.exist
 
           expect((job as RunnerJobAdmin).privatePayload).to.not.exist
         }
diff --git a/server/tests/api/runners/runner-studio-transcoding.ts b/server/tests/api/runners/runner-studio-transcoding.ts
new file mode 100644 (file)
index 0000000..9ae629b
--- /dev/null
@@ -0,0 +1,168 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { readFile } from 'fs-extra'
+import { checkPersistentTmpIsEmpty, checkVideoDuration } from '@server/tests/shared'
+import { buildAbsoluteFixturePath } from '@shared/core-utils'
+import {
+  RunnerJobVideoEditionTranscodingPayload,
+  VideoEditionTranscodingSuccess,
+  VideoState,
+  VideoStudioTask,
+  VideoStudioTaskIntro
+} from '@shared/models'
+import {
+  cleanupTests,
+  createMultipleServers,
+  doubleFollow,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  setDefaultVideoChannel,
+  VideoStudioCommand,
+  waitJobs
+} from '@shared/server-commands'
+
+describe('Test runner video studio transcoding', function () {
+  let servers: PeerTubeServer[] = []
+  let runnerToken: string
+  let videoUUID: string
+  let jobUUID: string
+
+  async function renewStudio (tasks: VideoStudioTask[] = VideoStudioCommand.getComplexTask()) {
+    const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
+    videoUUID = uuid
+
+    await waitJobs(servers)
+
+    await servers[0].videoStudio.createEditionTasks({ videoId: uuid, tasks })
+    await waitJobs(servers)
+
+    const { availableJobs } = await servers[0].runnerJobs.request({ runnerToken })
+    expect(availableJobs).to.have.lengthOf(1)
+
+    jobUUID = availableJobs[0].uuid
+  }
+
+  before(async function () {
+    this.timeout(120_000)
+
+    servers = await createMultipleServers(2)
+
+    await setAccessTokensToServers(servers)
+    await setDefaultVideoChannel(servers)
+
+    await doubleFollow(servers[0], servers[1])
+
+    await servers[0].config.enableTranscoding(true, true)
+    await servers[0].config.enableStudio()
+    await servers[0].config.enableRemoteStudio()
+
+    runnerToken = await servers[0].runners.autoRegisterRunner()
+  })
+
+  it('Should error a studio transcoding job', async function () {
+    this.timeout(60000)
+
+    await renewStudio()
+
+    for (let i = 0; i < 5; i++) {
+      const { job } = await servers[0].runnerJobs.accept({ runnerToken, jobUUID })
+      const jobToken = job.jobToken
+
+      await servers[0].runnerJobs.error({ runnerToken, jobUUID, jobToken, message: 'Error' })
+    }
+
+    const video = await servers[0].videos.get({ id: videoUUID })
+    expect(video.state.id).to.equal(VideoState.PUBLISHED)
+
+    await checkPersistentTmpIsEmpty(servers[0])
+  })
+
+  it('Should cancel a transcoding job', async function () {
+    this.timeout(60000)
+
+    await renewStudio()
+
+    await servers[0].runnerJobs.cancelByAdmin({ jobUUID })
+
+    const video = await servers[0].videos.get({ id: videoUUID })
+    expect(video.state.id).to.equal(VideoState.PUBLISHED)
+
+    await checkPersistentTmpIsEmpty(servers[0])
+  })
+
+  it('Should execute a remote studio job', async function () {
+    this.timeout(240_000)
+
+    const tasks = [
+      {
+        name: 'add-outro' as 'add-outro',
+        options: {
+          file: 'video_short.webm'
+        }
+      },
+      {
+        name: 'add-watermark' as 'add-watermark',
+        options: {
+          file: 'thumbnail.png'
+        }
+      },
+      {
+        name: 'add-intro' as 'add-intro',
+        options: {
+          file: 'video_very_short_240p.mp4'
+        }
+      }
+    ]
+
+    await renewStudio(tasks)
+
+    for (const server of servers) {
+      await checkVideoDuration(server, videoUUID, 5)
+    }
+
+    const { job } = await servers[0].runnerJobs.accept<RunnerJobVideoEditionTranscodingPayload>({ runnerToken, jobUUID })
+    const jobToken = job.jobToken
+
+    expect(job.type === 'video-edition-transcoding')
+    expect(job.payload.input.videoFileUrl).to.exist
+
+    // Check video input file
+    {
+      await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+    }
+
+    // Check task files
+    for (let i = 0; i < tasks.length; i++) {
+      const task = tasks[i]
+      const payloadTask = job.payload.tasks[i]
+
+      expect(payloadTask.name).to.equal(task.name)
+
+      const inputFile = await readFile(buildAbsoluteFixturePath(task.options.file))
+
+      const { body } = await servers[0].runnerJobs.getJobFile({
+        url: (payloadTask as VideoStudioTaskIntro).options.file as string,
+        jobToken,
+        runnerToken
+      })
+
+      expect(body).to.deep.equal(inputFile)
+    }
+
+    const payload: VideoEditionTranscodingSuccess = { videoFile: 'video_very_short_240p.mp4' }
+    await servers[0].runnerJobs.success({ runnerToken, jobUUID, jobToken, payload })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      await checkVideoDuration(server, videoUUID, 2)
+    }
+
+    await checkPersistentTmpIsEmpty(servers[0])
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
index 92a47ac3ba77126a8866d2ca8259e752c421f60f..b08ee312c60ab6bb4da0b52cde1431a400a77074 100644 (file)
@@ -155,7 +155,7 @@ describe('Test runner VOD transcoding', function () {
       expect(job.payload.output.resolution).to.equal(720)
       expect(job.payload.output.fps).to.equal(25)
 
-      const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+      const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
       const inputFile = await readFile(buildAbsoluteFixturePath('video_short.webm'))
 
       expect(body).to.deep.equal(inputFile)
@@ -200,7 +200,7 @@ describe('Test runner VOD transcoding', function () {
       const { job } = await servers[0].runnerJobs.accept<RunnerJobVODWebVideoTranscodingPayload>({ runnerToken, jobUUID })
       jobToken = job.jobToken
 
-      const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+      const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
       const inputFile = await readFile(buildAbsoluteFixturePath('video_short.mp4'))
 
       expect(body).to.deep.equal(inputFile)
@@ -221,7 +221,7 @@ describe('Test runner VOD transcoding', function () {
         const { job } = await servers[0].runnerJobs.accept<RunnerJobVODWebVideoTranscodingPayload>({ runnerToken, jobUUID })
         jobToken = job.jobToken
 
-        const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+        const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
         const inputFile = await readFile(buildAbsoluteFixturePath('video_short.mp4'))
         expect(body).to.deep.equal(inputFile)
 
@@ -293,7 +293,7 @@ describe('Test runner VOD transcoding', function () {
       const { job } = await servers[0].runnerJobs.accept<RunnerJobVODHLSTranscodingPayload>({ runnerToken, jobUUID })
       jobToken = job.jobToken
 
-      const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+      const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
       const inputFile = await readFile(buildAbsoluteFixturePath('video_short.mp4'))
 
       expect(body).to.deep.equal(inputFile)
@@ -337,7 +337,7 @@ describe('Test runner VOD transcoding', function () {
         const { job } = await servers[0].runnerJobs.accept<RunnerJobVODHLSTranscodingPayload>({ runnerToken, jobUUID })
         jobToken = job.jobToken
 
-        const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+        const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
         const inputFile = await readFile(buildAbsoluteFixturePath(maxQualityFile))
         expect(body).to.deep.equal(inputFile)
 
@@ -446,13 +446,13 @@ describe('Test runner VOD transcoding', function () {
       expect(job.payload.output.resolution).to.equal(480)
 
       {
-        const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.audioFileUrl, jobToken, runnerToken })
+        const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.audioFileUrl, jobToken, runnerToken })
         const inputFile = await readFile(buildAbsoluteFixturePath('sample.ogg'))
         expect(body).to.deep.equal(inputFile)
       }
 
       {
-        const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.previewFileUrl, jobToken, runnerToken })
+        const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.previewFileUrl, jobToken, runnerToken })
 
         const video = await servers[0].videos.get({ id: videoUUID })
         const { body: inputFile } = await makeGetRequest({
@@ -503,7 +503,7 @@ describe('Test runner VOD transcoding', function () {
       const { job } = await servers[0].runnerJobs.accept<RunnerJobVODHLSTranscodingPayload>({ runnerToken, jobUUID })
       jobToken = job.jobToken
 
-      const { body } = await servers[0].runnerJobs.getInputFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
+      const { body } = await servers[0].runnerJobs.getJobFile({ url: job.payload.input.videoFileUrl, jobToken, runnerToken })
       const inputFile = await readFile(buildAbsoluteFixturePath('video_short_480p.mp4'))
       expect(body).to.deep.equal(inputFile)
 
index 54a40b994c7d65baa9145be3ca6f6bdeed1ab602..011ba268caa465adec5e5ec11ad2cc16d73a5eb3 100644 (file)
@@ -102,6 +102,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
   expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.true
 
   expect(data.videoStudio.enabled).to.be.false
+  expect(data.videoStudio.remoteRunners.enabled).to.be.false
 
   expect(data.import.videos.concurrency).to.equal(2)
   expect(data.import.videos.http.enabled).to.be.true
@@ -211,6 +212,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.live.transcoding.alwaysTranscodeOriginalResolution).to.be.false
 
   expect(data.videoStudio.enabled).to.be.true
+  expect(data.videoStudio.remoteRunners.enabled).to.be.true
 
   expect(data.import.videos.concurrency).to.equal(4)
   expect(data.import.videos.http.enabled).to.be.false
@@ -374,7 +376,10 @@ const newCustomConfig: CustomConfig = {
     }
   },
   videoStudio: {
-    enabled: true
+    enabled: true,
+    remoteRunners: {
+      enabled: true
+    }
   },
   import: {
     videos: {
index 30f72e6e9da95e4fa0a2b1ef0f26d20fca90e5dc..2f64ef6bdb88c4f6019ff5012a61a99af563e625 100644 (file)
@@ -1,5 +1,5 @@
 import { expect } from 'chai'
-import { checkPersistentTmpIsEmpty, expectStartWith } from '@server/tests/shared'
+import { checkPersistentTmpIsEmpty, checkVideoDuration, expectStartWith } from '@server/tests/shared'
 import { areMockObjectStorageTestsDisabled, getAllFiles } from '@shared/core-utils'
 import { VideoStudioTask } from '@shared/models'
 import {
@@ -18,20 +18,6 @@ describe('Test video studio', function () {
   let servers: PeerTubeServer[] = []
   let videoUUID: string
 
-  async function checkDuration (server: PeerTubeServer, duration: number) {
-    const video = await server.videos.get({ id: videoUUID })
-
-    expect(video.duration).to.be.approximately(duration, 1)
-
-    for (const file of video.files) {
-      const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
-
-      for (const stream of metadata.streams) {
-        expect(Math.round(stream.duration)).to.be.approximately(duration, 1)
-      }
-    }
-  }
-
   async function renewVideo (fixture = 'video_short.webm') {
     const video = await servers[0].videos.quickUpload({ name: 'video', fixture })
     videoUUID = video.uuid
@@ -79,7 +65,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 3)
+        await checkVideoDuration(server, videoUUID, 3)
 
         const video = await server.videos.get({ id: videoUUID })
         expect(new Date(video.publishedAt)).to.be.below(beforeTasks)
@@ -100,7 +86,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 2)
+        await checkVideoDuration(server, videoUUID, 2)
       }
     })
 
@@ -119,7 +105,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 4)
+        await checkVideoDuration(server, videoUUID, 4)
       }
     })
   })
@@ -140,7 +126,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 10)
+        await checkVideoDuration(server, videoUUID, 10)
       }
     })
 
@@ -158,7 +144,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 7)
+        await checkVideoDuration(server, videoUUID, 7)
       }
     })
 
@@ -183,7 +169,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 12)
+        await checkVideoDuration(server, videoUUID, 12)
       }
     })
 
@@ -201,7 +187,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 7)
+        await checkVideoDuration(server, videoUUID, 7)
       }
     })
 
@@ -219,7 +205,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 10)
+        await checkVideoDuration(server, videoUUID, 10)
       }
     })
 
@@ -237,7 +223,7 @@ describe('Test video studio', function () {
       ])
 
       for (const server of servers) {
-        await checkDuration(server, 10)
+        await checkVideoDuration(server, videoUUID, 10)
       }
     })
   })
@@ -279,7 +265,7 @@ describe('Test video studio', function () {
       await createTasks(VideoStudioCommand.getComplexTask())
 
       for (const server of servers) {
-        await checkDuration(server, 9)
+        await checkVideoDuration(server, videoUUID, 9)
       }
     })
   })
@@ -309,7 +295,7 @@ describe('Test video studio', function () {
         const video = await server.videos.get({ id: videoUUID })
         expect(video.files).to.have.lengthOf(0)
 
-        await checkDuration(server, 9)
+        await checkVideoDuration(server, videoUUID, 9)
       }
     })
   })
@@ -351,7 +337,7 @@ describe('Test video studio', function () {
           expectStartWith(hlsFile.fileUrl, ObjectStorageCommand.getMockPlaylistBaseUrl())
         }
 
-        await checkDuration(server, 9)
+        await checkVideoDuration(server, videoUUID, 9)
       }
     })
   })
@@ -370,7 +356,7 @@ describe('Test video studio', function () {
       await waitJobs(servers)
 
       for (const server of servers) {
-        await checkDuration(server, 9)
+        await checkVideoDuration(server, videoUUID, 9)
       }
     })
 
index 6258d6eb25376708f52311a8fe0185c94c984f54..470316417388de06b7face99ba13f4ac660f5458 100644 (file)
@@ -1,3 +1,4 @@
 export * from './client-cli'
 export * from './live-transcoding'
+export * from './studio-transcoding'
 export * from './vod-transcoding'
index f58e920ba6c428a3d4156716e09b24881d978320..1e94eabcdd89c8304f4e38d88d6870d17d3b6f2a 100644 (file)
@@ -1,6 +1,12 @@
 import { expect } from 'chai'
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
-import { expectStartWith, PeerTubeRunnerProcess, SQLCommand, testLiveVideoResolutions } from '@server/tests/shared'
+import {
+  checkPeerTubeRunnerCacheIsEmpty,
+  expectStartWith,
+  PeerTubeRunnerProcess,
+  SQLCommand,
+  testLiveVideoResolutions
+} from '@server/tests/shared'
 import { areMockObjectStorageTestsDisabled, wait } from '@shared/core-utils'
 import { HttpStatusCode, VideoPrivacy } from '@shared/models'
 import {
@@ -169,6 +175,13 @@ describe('Test Live transcoding in peertube-runner program', function () {
     runSuite({ objectStorage: true })
   })
 
+  describe('Check cleanup', function () {
+
+    it('Should have an empty cache directory', async function () {
+      await checkPeerTubeRunnerCacheIsEmpty()
+    })
+  })
+
   after(async function () {
     await peertubeRunner.unregisterPeerTubeInstance({ server: servers[0] })
     peertubeRunner.kill()
diff --git a/server/tests/peertube-runner/studio-transcoding.ts b/server/tests/peertube-runner/studio-transcoding.ts
new file mode 100644 (file)
index 0000000..cca905e
--- /dev/null
@@ -0,0 +1,116 @@
+
+import { expect } from 'chai'
+import { checkPeerTubeRunnerCacheIsEmpty, checkVideoDuration, expectStartWith, PeerTubeRunnerProcess } from '@server/tests/shared'
+import { areMockObjectStorageTestsDisabled, getAllFiles, wait } from '@shared/core-utils'
+import {
+  cleanupTests,
+  createMultipleServers,
+  doubleFollow,
+  ObjectStorageCommand,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  setDefaultVideoChannel,
+  VideoStudioCommand,
+  waitJobs
+} from '@shared/server-commands'
+
+describe('Test studio transcoding in peertube-runner program', function () {
+  let servers: PeerTubeServer[] = []
+  let peertubeRunner: PeerTubeRunnerProcess
+
+  function runSuite (options: {
+    objectStorage: boolean
+  }) {
+    const { objectStorage } = options
+
+    it('Should run a complex studio transcoding', async function () {
+      this.timeout(120000)
+
+      const { uuid } = await servers[0].videos.quickUpload({ name: 'mp4', fixture: 'video_short.mp4' })
+      await waitJobs(servers)
+
+      const video = await servers[0].videos.get({ id: uuid })
+      const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+      await servers[0].videoStudio.createEditionTasks({ videoId: uuid, tasks: VideoStudioCommand.getComplexTask() })
+      await waitJobs(servers, { runnerJobs: true })
+
+      for (const server of servers) {
+        const video = await server.videos.get({ id: uuid })
+        const files = getAllFiles(video)
+
+        for (const f of files) {
+          expect(oldFileUrls).to.not.include(f.fileUrl)
+        }
+
+        if (objectStorage) {
+          for (const webtorrentFile of video.files) {
+            expectStartWith(webtorrentFile.fileUrl, ObjectStorageCommand.getMockWebTorrentBaseUrl())
+          }
+
+          for (const hlsFile of video.streamingPlaylists[0].files) {
+            expectStartWith(hlsFile.fileUrl, ObjectStorageCommand.getMockPlaylistBaseUrl())
+          }
+        }
+
+        await checkVideoDuration(server, uuid, 9)
+      }
+    })
+  }
+
+  before(async function () {
+    this.timeout(120_000)
+
+    servers = await createMultipleServers(2)
+
+    await setAccessTokensToServers(servers)
+    await setDefaultVideoChannel(servers)
+
+    await doubleFollow(servers[0], servers[1])
+
+    await servers[0].config.enableTranscoding(true, true)
+    await servers[0].config.enableStudio()
+    await servers[0].config.enableRemoteStudio()
+
+    const registrationToken = await servers[0].runnerRegistrationTokens.getFirstRegistrationToken()
+
+    peertubeRunner = new PeerTubeRunnerProcess()
+    await peertubeRunner.runServer({ hideLogs: false })
+    await peertubeRunner.registerPeerTubeInstance({ server: servers[0], registrationToken, runnerName: 'runner' })
+  })
+
+  describe('With videos on local filesystem storage', function () {
+    runSuite({ objectStorage: false })
+  })
+
+  describe('With videos on object storage', function () {
+    if (areMockObjectStorageTestsDisabled()) return
+
+    before(async function () {
+      await ObjectStorageCommand.prepareDefaultMockBuckets()
+
+      await servers[0].kill()
+
+      await servers[0].run(ObjectStorageCommand.getDefaultMockConfig())
+
+      // Wait for peertube runner socket reconnection
+      await wait(1500)
+    })
+
+    runSuite({ objectStorage: true })
+  })
+
+  describe('Check cleanup', function () {
+
+    it('Should have an empty cache directory', async function () {
+      await checkPeerTubeRunnerCacheIsEmpty()
+    })
+  })
+
+  after(async function () {
+    await peertubeRunner.unregisterPeerTubeInstance({ server: servers[0] })
+    peertubeRunner.kill()
+
+    await cleanupTests(servers)
+  })
+})
index bdf798379515edc0bf437fd727576fdbd43dbf7b..3a9abba93e730b1d14e50be9f8ef514177997133 100644 (file)
@@ -1,6 +1,11 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 import { expect } from 'chai'
-import { completeCheckHlsPlaylist, completeWebVideoFilesCheck, PeerTubeRunnerProcess } from '@server/tests/shared'
+import {
+  checkPeerTubeRunnerCacheIsEmpty,
+  completeCheckHlsPlaylist,
+  completeWebVideoFilesCheck,
+  PeerTubeRunnerProcess
+} from '@server/tests/shared'
 import { areMockObjectStorageTestsDisabled, getAllFiles, wait } from '@shared/core-utils'
 import { VideoPrivacy } from '@shared/models'
 import {
@@ -321,6 +326,13 @@ describe('Test VOD transcoding in peertube-runner program', function () {
     })
   })
 
+  describe('Check cleanup', function () {
+
+    it('Should have an empty cache directory', async function () {
+      await checkPeerTubeRunnerCacheIsEmpty()
+    })
+  })
+
   after(async function () {
     await peertubeRunner.unregisterPeerTubeInstance({ server: servers[0] })
     peertubeRunner.kill()
index d7eb25bb54ccfbbae67a5801781561a9bd8ff9d4..feaef37c67ab43cad6e1f0dc7bf449e7b931b2f2 100644 (file)
@@ -130,6 +130,22 @@ function checkBadSortPagination (url: string, path: string, token?: string, quer
   })
 }
 
+// ---------------------------------------------------------------------------
+
+async function checkVideoDuration (server: PeerTubeServer, videoUUID: string, duration: number) {
+  const video = await server.videos.get({ id: videoUUID })
+
+  expect(video.duration).to.be.approximately(duration, 1)
+
+  for (const file of video.files) {
+    const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
+
+    for (const stream of metadata.streams) {
+      expect(Math.round(stream.duration)).to.be.approximately(duration, 1)
+    }
+  }
+}
+
 export {
   dateIsValid,
   testImageSize,
@@ -142,5 +158,6 @@ export {
   checkBadStartPagination,
   checkBadCountPagination,
   checkBadSortPagination,
+  checkVideoDuration,
   expectLogContain
 }
index a614cef7c0f7c5ffc6a12a7741eb935707d8ef82..4f42825541c6d844afbe0843acbf2508fdaa6c9c 100644 (file)
@@ -2,9 +2,11 @@
 
 import { expect } from 'chai'
 import { pathExists, readdir } from 'fs-extra'
+import { homedir } from 'os'
+import { join } from 'path'
 import { PeerTubeServer } from '@shared/server-commands'
 
-async function checkTmpIsEmpty (server: PeerTubeServer) {
+export async function checkTmpIsEmpty (server: PeerTubeServer) {
   await checkDirectoryIsEmpty(server, 'tmp', [ 'plugins-global.css', 'hls', 'resumable-uploads' ])
 
   if (await pathExists(server.getDirectoryPath('tmp/hls'))) {
@@ -12,11 +14,11 @@ async function checkTmpIsEmpty (server: PeerTubeServer) {
   }
 }
 
-async function checkPersistentTmpIsEmpty (server: PeerTubeServer) {
+export async function checkPersistentTmpIsEmpty (server: PeerTubeServer) {
   await checkDirectoryIsEmpty(server, 'tmp-persistent')
 }
 
-async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) {
+export async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string, exceptions: string[] = []) {
   const directoryPath = server.getDirectoryPath(directory)
 
   const directoryExists = await pathExists(directoryPath)
@@ -28,8 +30,13 @@ async function checkDirectoryIsEmpty (server: PeerTubeServer, directory: string,
   expect(filtered).to.have.lengthOf(0)
 }
 
-export {
-  checkTmpIsEmpty,
-  checkPersistentTmpIsEmpty,
-  checkDirectoryIsEmpty
+export async function checkPeerTubeRunnerCacheIsEmpty () {
+  const directoryPath = join(homedir(), '.cache', 'peertube-runner-nodejs', 'test', 'transcoding')
+
+  const directoryExists = await pathExists(directoryPath)
+  expect(directoryExists).to.be.true
+
+  const files = await readdir(directoryPath)
+
+  expect(files).to.have.lengthOf(0)
 }
index 8f0c17135c9c2a6bc50371da02cfde83ae0bf112..9f0db0dc40c3568898130748dac64b17cc7e7e4f 100644 (file)
@@ -1,3 +1,5 @@
+import { VideoStudioTaskPayload } from '../server'
+
 export type RunnerJobVODPayload =
   RunnerJobVODWebVideoTranscodingPayload |
   RunnerJobVODHLSTranscodingPayload |
@@ -5,7 +7,8 @@ export type RunnerJobVODPayload =
 
 export type RunnerJobPayload =
   RunnerJobVODPayload |
-  RunnerJobLiveRTMPHLSTranscodingPayload
+  RunnerJobLiveRTMPHLSTranscodingPayload |
+  RunnerJobVideoEditionTranscodingPayload
 
 // ---------------------------------------------------------------------------
 
@@ -43,6 +46,14 @@ export interface RunnerJobVODAudioMergeTranscodingPayload {
   }
 }
 
+export interface RunnerJobVideoEditionTranscodingPayload {
+  input: {
+    videoFileUrl: string
+  }
+
+  tasks: VideoStudioTaskPayload[]
+}
+
 // ---------------------------------------------------------------------------
 
 export function isAudioMergeTranscodingPayload (payload: RunnerJobPayload): payload is RunnerJobVODAudioMergeTranscodingPayload {
index c1d8d1045e0bdb2dd4145f7dd7a537448c56ad6a..c8fe0a7d83e57f81a5e90ec0fee67cfc99a24cce 100644 (file)
@@ -1,3 +1,5 @@
+import { VideoStudioTaskPayload } from '../server'
+
 export type RunnerJobVODPrivatePayload =
   RunnerJobVODWebVideoTranscodingPrivatePayload |
   RunnerJobVODAudioMergeTranscodingPrivatePayload |
@@ -5,7 +7,8 @@ export type RunnerJobVODPrivatePayload =
 
 export type RunnerJobPrivatePayload =
   RunnerJobVODPrivatePayload |
-  RunnerJobLiveRTMPHLSTranscodingPrivatePayload
+  RunnerJobLiveRTMPHLSTranscodingPrivatePayload |
+  RunnerJobVideoEditionTranscodingPrivatePayload
 
 // ---------------------------------------------------------------------------
 
@@ -32,3 +35,10 @@ export interface RunnerJobLiveRTMPHLSTranscodingPrivatePayload {
   masterPlaylistName: string
   outputDirectory: string
 }
+
+// ---------------------------------------------------------------------------
+
+export interface RunnerJobVideoEditionTranscodingPrivatePayload {
+  videoUUID: string
+  originalTasks: VideoStudioTaskPayload[]
+}
index 223b7552d4ffda723f20805d9bea66224a9a2394..17e921f6968797c82168e536eb4074e13e0524c0 100644 (file)
@@ -11,7 +11,8 @@ export type RunnerJobSuccessPayload =
   VODWebVideoTranscodingSuccess |
   VODHLSTranscodingSuccess |
   VODAudioMergeTranscodingSuccess |
-  LiveRTMPHLSTranscodingSuccess
+  LiveRTMPHLSTranscodingSuccess |
+  VideoEditionTranscodingSuccess
 
 export interface VODWebVideoTranscodingSuccess {
   videoFile: Blob | string
@@ -30,6 +31,10 @@ export interface LiveRTMPHLSTranscodingSuccess {
 
 }
 
+export interface VideoEditionTranscodingSuccess {
+  videoFile: Blob | string
+}
+
 export function isWebVideoOrAudioMergeTranscodingPayloadSuccess (
   payload: RunnerJobSuccessPayload
 ): payload is VODHLSTranscodingSuccess | VODAudioMergeTranscodingSuccess {
index 36d3b9b2564c1b2c3478ff8844e41ee7e58348fe..3b997cb6e101e23eb9be8bdf2e2efe85c1c72615 100644 (file)
@@ -2,4 +2,5 @@ export type RunnerJobType =
   'vod-web-video-transcoding' |
   'vod-hls-transcoding' |
   'vod-audio-merge-transcoding' |
-  'live-rtmp-hls-transcoding'
+  'live-rtmp-hls-transcoding' |
+  'video-edition-transcoding'
index 5d2c10278b453b7f95dcfc62a3b96f429fb64993..4202589f3efa1e8b1cfbffe504d464dc4faf9816 100644 (file)
@@ -165,6 +165,10 @@ export interface CustomConfig {
 
   videoStudio: {
     enabled: boolean
+
+    remoteRunners: {
+      enabled: boolean
+    }
   }
 
   import: {
index 3fd5bf7f926300289a57a1144e2ef25c2c744367..22ecee324d0b0d2b440c04f7f31e94c6e41e5d02 100644 (file)
@@ -225,6 +225,10 @@ export type VideoStudioTaskWatermarkPayload = {
 
   options: {
     file: string
+
+    watermarkSizeRatio: number
+    horitonzalMarginRatio: number
+    verticalMarginRatio: number
   }
 }
 
index 38b9d0385ff9c61adfdb8ceeeb372135fa51170c..024ed35bffaedc5e9aea856d3cfb1379e25cca6e 100644 (file)
@@ -1,6 +1,6 @@
-import { VideoPrivacy } from '../videos/video-privacy.enum'
 import { ClientScriptJSON } from '../plugins/plugin-package-json.model'
 import { NSFWPolicyType } from '../videos/nsfw-policy.type'
+import { VideoPrivacy } from '../videos/video-privacy.enum'
 import { BroadcastMessageLevel } from './broadcast-message-level.type'
 
 export interface ServerConfigPlugin {
@@ -186,6 +186,10 @@ export interface ServerConfig {
 
   videoStudio: {
     enabled: boolean
+
+    remoteRunners: {
+      enabled: boolean
+    }
   }
 
   import: {
index 001d65c900f2261a414ca39d4b77541166352923..5e8296dc9f461fc94263f5921abf8089b7554cb6 100644 (file)
@@ -40,3 +40,21 @@ export interface VideoStudioTaskWatermark {
     file: Blob | string
   }
 }
+
+// ---------------------------------------------------------------------------
+
+export function isVideoStudioTaskIntro (v: VideoStudioTask): v is VideoStudioTaskIntro {
+  return v.name === 'add-intro'
+}
+
+export function isVideoStudioTaskOutro (v: VideoStudioTask): v is VideoStudioTaskOutro {
+  return v.name === 'add-outro'
+}
+
+export function isVideoStudioTaskWatermark (v: VideoStudioTask): v is VideoStudioTaskWatermark {
+  return v.name === 'add-watermark'
+}
+
+export function hasVideoStudioTaskFile (v: VideoStudioTask): v is VideoStudioTaskIntro | VideoStudioTaskOutro | VideoStudioTaskWatermark {
+  return isVideoStudioTaskIntro(v) || isVideoStudioTaskOutro(v) || isVideoStudioTaskWatermark(v)
+}
index 3b0f84b9d353c114d5d576526b44d025d9efa0a0..26dbef77ae0982a18d19a765308db459d25cfdc3 100644 (file)
@@ -200,7 +200,7 @@ export class RunnerJobsCommand extends AbstractCommand {
     })
   }
 
-  getInputFile (options: OverrideCommandOptions & { url: string, jobToken: string, runnerToken: string }) {
+  getJobFile (options: OverrideCommandOptions & { url: string, jobToken: string, runnerToken: string }) {
     const { host, protocol, pathname } = new URL(options.url)
 
     return this.postBodyRequest({
@@ -249,8 +249,15 @@ export class RunnerJobsCommand extends AbstractCommand {
 
     const { data } = await this.list({ count: 100 })
 
+    const allowedStates = new Set<RunnerJobState>([
+      RunnerJobState.PENDING,
+      RunnerJobState.PROCESSING,
+      RunnerJobState.WAITING_FOR_PARENT_JOB
+    ])
+
     for (const job of data) {
       if (state && job.state.id !== state) continue
+      else if (allowedStates.has(job.state.id) !== true) continue
 
       await this.cancelByAdmin({ jobUUID: job.uuid })
     }
index 9a6e413f2b4ecdcd70a23c1bf34850aa7ec3519a..b94bd2625a9cefea6b01def9c5a20c32f90d03e6 100644 (file)
@@ -195,6 +195,18 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
+  enableRemoteStudio () {
+    return this.updateExistingSubConfig({
+      newConfig: {
+        videoStudio: {
+          remoteRunners: {
+            enabled: true
+          }
+        }
+      }
+    })
+  }
+
   // ---------------------------------------------------------------------------
 
   enableStudio () {
@@ -442,7 +454,10 @@ export class ConfigCommand extends AbstractCommand {
         }
       },
       videoStudio: {
-        enabled: false
+        enabled: false,
+        remoteRunners: {
+          enabled: false
+        }
       },
       import: {
         videos: {