]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add basic video editor support
authorChocobozzz <me@florianbigard.com>
Fri, 11 Feb 2022 09:51:33 +0000 (10:51 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 28 Feb 2022 09:42:19 +0000 (10:42 +0100)
130 files changed:
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
client/src/app/+admin/overview/videos/video-list.component.scss
client/src/app/+my-library/my-videos/my-videos.component.ts
client/src/app/+video-editor/edit/index.ts [new file with mode: 0644]
client/src/app/+video-editor/edit/video-editor-edit.component.html [new file with mode: 0644]
client/src/app/+video-editor/edit/video-editor-edit.component.scss [new file with mode: 0644]
client/src/app/+video-editor/edit/video-editor-edit.component.ts [new file with mode: 0644]
client/src/app/+video-editor/edit/video-editor-edit.resolver.ts [new file with mode: 0644]
client/src/app/+video-editor/index.ts [new file with mode: 0644]
client/src/app/+video-editor/shared/index.ts [new file with mode: 0644]
client/src/app/+video-editor/shared/video-editor.service.ts [new file with mode: 0644]
client/src/app/+video-editor/video-editor-routing.module.ts [new file with mode: 0644]
client/src/app/+video-editor/video-editor.module.ts [new file with mode: 0644]
client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
client/src/app/app-routing.module.ts
client/src/app/shared/shared-forms/form-reactive.ts
client/src/app/shared/shared-forms/form-validator.service.ts
client/src/app/shared/shared-forms/timestamp-input.component.html
client/src/app/shared/shared-forms/timestamp-input.component.scss
client/src/app/shared/shared-forms/timestamp-input.component.ts
client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
client/src/app/shared/shared-video-miniature/video-miniature.component.ts
config/default.yaml
config/production.yaml.example
config/test-1.yaml
config/test-3.yaml
config/test-4.yaml
config/test-5.yaml
config/test-6.yaml
config/test.yaml
scripts/create-transcoding-job.ts
scripts/print-transcode-command.ts
server.ts
server/controllers/api/config.ts
server/controllers/api/videos/editor.ts [new file with mode: 0644]
server/controllers/api/videos/index.ts
server/controllers/api/videos/transcoding.ts
server/controllers/api/videos/upload.ts
server/helpers/custom-validators/actor-images.ts
server/helpers/custom-validators/misc.ts
server/helpers/custom-validators/video-captions.ts
server/helpers/custom-validators/video-editor.ts [new file with mode: 0644]
server/helpers/custom-validators/video-imports.ts
server/helpers/custom-validators/videos.ts
server/helpers/express-utils.ts
server/helpers/ffmpeg-utils.ts [deleted file]
server/helpers/ffmpeg/ffmpeg-commons.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffmpeg-edition.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffmpeg-encoders.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffmpeg-images.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffmpeg-live.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffmpeg-presets.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffmpeg-vod.ts [new file with mode: 0644]
server/helpers/ffmpeg/ffprobe-utils.ts [moved from server/helpers/ffprobe-utils.ts with 67% similarity]
server/helpers/ffmpeg/index.ts [new file with mode: 0644]
server/helpers/image-utils.ts
server/helpers/webtorrent.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/migrations/0075-video-resolutions.ts
server/lib/hls.ts
server/lib/job-queue/handlers/video-edition.ts [new file with mode: 0644]
server/lib/job-queue/handlers/video-file-import.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/job-queue/handlers/video-live-ending.ts
server/lib/job-queue/handlers/video-transcoding.ts
server/lib/job-queue/job-queue.ts
server/lib/live/live-manager.ts
server/lib/live/shared/muxing-session.ts
server/lib/plugins/plugin-helpers-builder.ts
server/lib/plugins/register-helpers.ts
server/lib/server-config-manager.ts
server/lib/thumbnail.ts
server/lib/transcoding/default-transcoding-profiles.ts [moved from server/lib/transcoding/video-transcoding-profiles.ts with 91% similarity]
server/lib/transcoding/transcoding.ts [moved from server/lib/transcoding/video-transcoding.ts with 92% similarity]
server/lib/user.ts
server/lib/video-editor.ts [new file with mode: 0644]
server/lib/video.ts
server/middlewares/validators/config.ts
server/middlewares/validators/shared/utils.ts
server/middlewares/validators/shared/videos.ts
server/middlewares/validators/videos/index.ts
server/middlewares/validators/videos/video-captions.ts
server/middlewares/validators/videos/video-comments.ts
server/middlewares/validators/videos/video-editor.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-ownership-changes.ts
server/middlewares/validators/videos/video-playlists.ts
server/middlewares/validators/videos/videos.ts
server/models/video/video.ts
server/tests/api/activitypub/refresher.ts
server/tests/api/check-params/config.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/video-editor.ts [new file with mode: 0644]
server/tests/api/live/live.ts
server/tests/api/search/search-channels.ts
server/tests/api/search/search-playlists.ts
server/tests/api/server/config.ts
server/tests/api/server/stats.ts
server/tests/api/videos/audio-only.ts
server/tests/api/videos/index.ts
server/tests/api/videos/video-editor.ts [new file with mode: 0644]
server/tests/api/videos/video-playlist-thumbnails.ts
server/tests/api/videos/video-playlists.ts
server/tests/api/videos/video-transcoder.ts
server/tests/cli/update-host.ts
server/tests/plugins/filter-hooks.ts
server/tests/plugins/plugin-transcoding.ts
server/tests/shared/generate.ts
server/tests/shared/videos.ts
server/types/express.d.ts
shared/extra-utils/ffprobe.ts
shared/models/server/custom-config.model.ts
shared/models/server/job.model.ts
shared/models/server/server-config.model.ts
shared/models/videos/editor/index.ts [new file with mode: 0644]
shared/models/videos/editor/video-editor-create-edit.model.ts [new file with mode: 0644]
shared/models/videos/index.ts
shared/models/videos/transcoding/video-transcoding-fps.model.ts
shared/models/videos/transcoding/video-transcoding.model.ts
shared/models/videos/video-state.enum.ts
shared/server-commands/server/config-command.ts
shared/server-commands/server/server.ts
shared/server-commands/videos/index.ts
shared/server-commands/videos/video-editor-command.ts [new file with mode: 0644]

index f2eaa30339f08bb903792cf2825f6e47e7033232..e3b6f8305722d01a7d5656ada9eeec236d78fa8a 100644 (file)
@@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
           resolutions: {}
         }
       },
+      videoEditor: {
+        enabled: null
+      },
       autoBlacklist: {
         videos: {
           ofUsers: {
index 1158f027bd71ceeee9d57a5c29a7e131f93d101e..2be85575625b04f272fb1252b53bf84c95426111 100644 (file)
 
     </div>
   </div>
+
+  <div class="form-row mt-2"> <!-- video editor grid -->
+    <div class="form-group col-12 col-lg-4 col-xl-3">
+      <div i18n class="inner-form-title">VIDEO EDITOR</div>
+      <div i18n class="inner-form-description">
+        Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
+      </div>
+    </div>
+
+    <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
+
+      <ng-container formGroupName="videoEditor">
+        <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
+          <my-peertube-checkbox
+            inputName="videoEditorEnabled" formControlName="enabled"
+            i18n-labelText labelText="Enable video editor"
+          >
+            <ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
+              <span i18n>⚠️ You need to enable transcoding first to enable video editor</span>
+            </ng-container>
+          </my-peertube-checkbox>
+        </div>
+      </ng-container>
+    </div>
+  </div>
 </ng-container>
index 3397c3dbd4fe6fa3db1be66f15f855eaaee8b6ba..948c10b69370cda10032713781f34aa900937251 100644 (file)
@@ -71,6 +71,8 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
   }
 
   private checkTranscodingFields () {
+    const transcodingControl = this.form.get('transcoding.enabled')
+    const videoEditorControl = this.form.get('videoEditor.enabled')
     const hlsControl = this.form.get('transcoding.hls.enabled')
     const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
 
@@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
                   webtorrentControl.enable()
                 }
               })
+
+    transcodingControl.valueChanges
+              .subscribe(newValue => {
+                if (newValue === false) {
+                  videoEditorControl.setValue(false)
+                }
+              })
   }
 }
index 543cb433c32f244b5aa314db5ecda4a382da0ca6..616b9bc6beeb936a4f333c281cfb8d2df57a643c 100644 (file)
@@ -1,5 +1,6 @@
 @use '_variables' as *;
 @use '_mixins' as *;
+
 my-embed {
   display: block;
   max-width: 500px;
index 261e87f996f8881e090f5564057ef1d634baa460..c998b7c490db86940e058f958800a6a6a8754516 100644 (file)
@@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
 import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
 import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
 import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
-import { VideoChannel, VideoSortField } from '@shared/models'
+import { VideoChannel, VideoSortField, VideoState } from '@shared/models'
 import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
 
 @Component({
@@ -204,6 +204,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
 
   private buildActions () {
     this.videoActions = [
+      {
+        label: $localize`Editor`,
+        linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
+        isDisplayed: ({ video }) => video.state.id === VideoState.PUBLISHED,
+        iconName: 'film'
+      },
       {
         label: $localize`Display live information`,
         handler: ({ video }) => this.displayLiveInformation(video),
diff --git a/client/src/app/+video-editor/edit/index.ts b/client/src/app/+video-editor/edit/index.ts
new file mode 100644 (file)
index 0000000..390ca80
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './video-editor-edit.component'
+export * from './video-editor-edit.resolver'
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.html b/client/src/app/+video-editor/edit/video-editor-edit.component.html
new file mode 100644 (file)
index 0000000..d33dfaf
--- /dev/null
@@ -0,0 +1,88 @@
+<div class="margin-content">
+  <h1 class="title-page title-page-single" i18n>Edit {{ video.name }}</h1>
+
+  <div class="columns">
+    <form role="form" [formGroup]="form">
+
+      <div class="section cut" formGroupName="cut">
+        <h2 i18n>CUT VIDEO</h2>
+
+        <div i18n class="description">Set a new start/end.</div>
+
+        <div class="form-group">
+          <label i18n for="cutStart">New start</label>
+          <my-timestamp-input inputName="cutStart" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="start"></my-timestamp-input>
+        </div>
+
+        <div class="form-group">
+          <label i18n for="cutEnd">New end</label>
+          <my-timestamp-input inputName="cutEnd" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="end"></my-timestamp-input>
+        </div>
+      </div>
+
+      <div class="section" formGroupName="add-intro">
+        <h2 i18n>ADD INTRO</h2>
+
+        <div i18n class="description">Concatenate a file at the beginning of the video.</div>
+
+        <div class="form-group">
+          <my-reactive-file
+            formControlName="file" inputName="addIntroFile" i18n-inputLabel inputLabel="Select the intro video file"
+            [extensions]="videoExtensions" [displayFilename]="true"
+            [ngbTooltip]="getIntroOutroTooltip()"
+          ></my-reactive-file>
+        </div>
+      </div>
+
+      <div class="section" formGroupName="add-outro">
+        <h2 i18n>ADD OUTRO</h2>
+
+        <div i18n class="description">Concatenate a file at the end of the video.</div>
+
+        <div class="form-group">
+          <my-reactive-file
+            formControlName="file" inputName="addOutroFile" i18n-inputLabel inputLabel="Select the outro video file"
+            [extensions]="videoExtensions" [displayFilename]="true"
+            [ngbTooltip]="getIntroOutroTooltip()"
+          ></my-reactive-file>
+        </div>
+      </div>
+
+      <div class="section" formGroupName="add-watermark">
+        <h2 i18n>ADD WATERMARK</h2>
+
+        <div i18n class="description">Add a watermark image to the video.</div>
+
+        <div class="form-group">
+          <my-reactive-file
+            formControlName="file" inputName="addWatermarkFile" i18n-inputLabel inputLabel="Select watermark image file"
+            [extensions]="imageExtensions" [displayFilename]="true"
+            [ngbTooltip]="getWatermarkTooltip()"
+          ></my-reactive-file>
+        </div>
+      </div>
+
+      <my-button
+        className="orange-button" i18n-label label="Run video edition" icon="circle-tick"
+        (click)="runEdition()" (keydown.enter)="runEdition()"
+        [disabled]="!form.valid || isRunningEdition || noEdition()"
+      ></my-button>
+    </form>
+
+
+    <div class="information">
+      <div>
+        <label i18n>Video before edition</label>
+        <my-embed [video]="video"></my-embed>
+      </div>
+
+      <div *ngIf="!noEdition()">
+        <label i18n>Edition tasks:</label>
+
+        <ol>
+          <li *ngFor="let task of getTasksSummary()">{{ task }}</li>
+        </ol>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.scss b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
new file mode 100644 (file)
index 0000000..43f336f
--- /dev/null
@@ -0,0 +1,76 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.columns {
+  display: flex;
+
+  .information {
+    width: 100%;
+    margin-left: 50px;
+
+    > div {
+      margin-bottom: 30px;
+    }
+
+    @media screen and (max-width: $small-view) {
+      display: none;
+    }
+  }
+}
+
+h1 {
+  font-size: 20px;
+}
+
+h2 {
+  font-weight: $font-bold;
+  font-size: 16px;
+  color: pvar(--mainColor);
+  background-color: pvar(--mainBackgroundColor);
+  padding: 0 5px;
+  width: fit-content;
+  margin: -8px 0 0;
+}
+
+.section {
+  $min-width: 600px;
+
+  @include padding-left(10px);
+
+  min-width: $min-width;
+
+  margin-bottom: 50px;
+  border: 1px solid $separator-border-color;
+  border-radius: 5px;
+  width: fit-content;
+
+  .form-group,
+  .description {
+    @include margin-left(5px);
+  }
+
+  .description {
+    color: pvar(--greyForegroundColor);
+    margin-top: 5px;
+    margin-bottom: 15px;
+  }
+
+  @media screen and (max-width: $min-width) {
+    min-width: none;
+  }
+}
+
+my-timestamp-input {
+  display: block;
+}
+
+my-embed {
+  display: block;
+  max-width: 500px;
+  width: 100%;
+}
+
+my-reactive-file {
+  display: block;
+  width: fit-content;
+}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.ts b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
new file mode 100644 (file)
index 0000000..93d7ffc
--- /dev/null
@@ -0,0 +1,202 @@
+import { Component, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { ConfirmService, Notifier, ServerService } from '@app/core'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { Video, VideoDetails } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { secondsToTime } from '@shared/core-utils'
+import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
+import { VideoEditorService } from '../shared'
+
+@Component({
+  selector: 'my-video-editor-edit',
+  templateUrl: './video-editor-edit.component.html',
+  styleUrls: [ './video-editor-edit.component.scss' ]
+})
+export class VideoEditorEditComponent extends FormReactive implements OnInit {
+  isRunningEdition = false
+
+  video: VideoDetails
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private serverService: ServerService,
+    private notifier: Notifier,
+    private router: Router,
+    private route: ActivatedRoute,
+    private videoEditorService: VideoEditorService,
+    private loadingBar: LoadingBarService,
+    private confirmService: ConfirmService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.video = this.route.snapshot.data.video
+
+    const defaultValues = {
+      cut: {
+        start: 0,
+        end: this.video.duration
+      }
+    }
+
+    this.buildForm({
+      cut: {
+        start: null,
+        end: null
+      },
+      'add-intro': {
+        file: null
+      },
+      'add-outro': {
+        file: null
+      },
+      'add-watermark': {
+        file: null
+      }
+    }, defaultValues)
+  }
+
+  get videoExtensions () {
+    return this.serverService.getHTMLConfig().video.file.extensions
+  }
+
+  get imageExtensions () {
+    return this.serverService.getHTMLConfig().video.image.extensions
+  }
+
+  async runEdition () {
+    if (this.isRunningEdition) return
+
+    const title = $localize`Are you sure you want to edit "${this.video.name}"?`
+    const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
+
+    // eslint-disable-next-line max-len
+    const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
+      $localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
+
+    if (await this.confirmService.confirm(confirmHTML, title) !== true) return
+
+    this.isRunningEdition = true
+
+    const tasks = this.buildTasks()
+
+    this.loadingBar.useRef().start()
+
+    return this.videoEditorService.editVideo(this.video.uuid, tasks)
+      .subscribe({
+        next: () => {
+          this.notifier.success($localize`Video updated.`)
+          this.router.navigateByUrl(Video.buildWatchUrl(this.video))
+        },
+
+        error: err => {
+          this.loadingBar.useRef().complete()
+          this.isRunningEdition = false
+          this.notifier.error(err.message)
+          console.error(err)
+        }
+      })
+  }
+
+  getIntroOutroTooltip () {
+    return $localize`(extensions: ${this.videoExtensions.join(', ')})`
+  }
+
+  getWatermarkTooltip () {
+    return $localize`(extensions: ${this.imageExtensions.join(', ')})`
+  }
+
+  noEdition () {
+    return this.buildTasks().length === 0
+  }
+
+  getTasksSummary () {
+    const tasks = this.buildTasks()
+
+    return tasks.map(t => {
+      if (t.name === 'add-intro') {
+        return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
+      }
+
+      if (t.name === 'add-outro') {
+        return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
+      }
+
+      if (t.name === 'add-watermark') {
+        return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
+      }
+
+      if (t.name === 'cut') {
+        const { start, end } = t.options
+
+        if (start !== undefined && end !== undefined) {
+          return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
+        }
+
+        if (start !== undefined) {
+          return $localize`Video will begin at ${secondsToTime(start)}`
+        }
+
+        if (end !== undefined) {
+          return $localize`Video will stop at ${secondsToTime(end)}`
+        }
+      }
+
+      return ''
+    })
+  }
+
+  private getFilename (obj: any) {
+    return obj.name
+  }
+
+  private buildTasks () {
+    const tasks: VideoEditorTask[] = []
+    const value = this.form.value
+
+    const cut = value['cut']
+    if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
+
+      const options: VideoEditorTaskCut['options'] = {}
+      if (cut['start'] !== 0) options.start = cut['start']
+      if (cut['end'] !== this.video.duration) options.end = cut['end']
+
+      tasks.push({
+        name: 'cut',
+        options
+      })
+    }
+
+    if (value['add-intro']?.['file']) {
+      tasks.push({
+        name: 'add-intro',
+        options: {
+          file: value['add-intro']['file']
+        }
+      })
+    }
+
+    if (value['add-outro']?.['file']) {
+      tasks.push({
+        name: 'add-outro',
+        options: {
+          file: value['add-outro']['file']
+        }
+      })
+    }
+
+    if (value['add-watermark']?.['file']) {
+      tasks.push({
+        name: 'add-watermark',
+        options: {
+          file: value['add-watermark']['file']
+        }
+      })
+    }
+
+    return tasks
+  }
+
+}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
new file mode 100644 (file)
index 0000000..7b95ae8
--- /dev/null
@@ -0,0 +1,18 @@
+
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
+import { VideoService } from '@app/shared/shared-main'
+
+@Injectable()
+export class VideoEditorEditResolver implements Resolve<any> {
+  constructor (
+    private videoService: VideoService
+  ) {
+  }
+
+  resolve (route: ActivatedRouteSnapshot) {
+    const videoId: string = route.params['videoId']
+
+    return this.videoService.getVideo({ videoId })
+  }
+}
diff --git a/client/src/app/+video-editor/index.ts b/client/src/app/+video-editor/index.ts
new file mode 100644 (file)
index 0000000..5a9e9fd
--- /dev/null
@@ -0,0 +1 @@
+export * from './video-editor.module'
diff --git a/client/src/app/+video-editor/shared/index.ts b/client/src/app/+video-editor/shared/index.ts
new file mode 100644 (file)
index 0000000..eaf88b6
--- /dev/null
@@ -0,0 +1 @@
+export * from './video-editor.service'
diff --git a/client/src/app/+video-editor/shared/video-editor.service.ts b/client/src/app/+video-editor/shared/video-editor.service.ts
new file mode 100644 (file)
index 0000000..5b70530
--- /dev/null
@@ -0,0 +1,28 @@
+import { catchError } from 'rxjs'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { objectToFormData } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models'
+
+@Injectable()
+export class VideoEditorService {
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) {}
+
+  editVideo (videoId: number | string, tasks: VideoEditorTask[]) {
+    const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit'
+    const body: VideoEditorCreateEdition = {
+      tasks
+    }
+
+    const data = objectToFormData(body)
+
+    return this.authHttp.post(url, data)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+}
diff --git a/client/src/app/+video-editor/video-editor-routing.module.ts b/client/src/app/+video-editor/video-editor-routing.module.ts
new file mode 100644 (file)
index 0000000..9f37a0d
--- /dev/null
@@ -0,0 +1,30 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { VideoEditorEditResolver } from './edit'
+import { VideoEditorEditComponent } from './edit/video-editor-edit.component'
+
+const videoEditorRoutes: Routes = [
+  {
+    path: '',
+    children: [
+      {
+        path: 'edit/:videoId',
+        component: VideoEditorEditComponent,
+        data: {
+          meta: {
+            title: $localize`Edit video`
+          }
+        },
+        resolve: {
+          video: VideoEditorEditResolver
+        }
+      }
+    ]
+  }
+]
+
+@NgModule({
+  imports: [ RouterModule.forChild(videoEditorRoutes) ],
+  exports: [ RouterModule ]
+})
+export class VideoEditorRoutingModule {}
diff --git a/client/src/app/+video-editor/video-editor.module.ts b/client/src/app/+video-editor/video-editor.module.ts
new file mode 100644 (file)
index 0000000..7bbebc1
--- /dev/null
@@ -0,0 +1,27 @@
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit'
+import { VideoEditorService } from './shared'
+import { VideoEditorRoutingModule } from './video-editor-routing.module'
+
+@NgModule({
+  imports: [
+    VideoEditorRoutingModule,
+
+    SharedMainModule,
+    SharedFormModule
+  ],
+
+  declarations: [
+    VideoEditorEditComponent
+  ],
+
+  exports: [],
+
+  providers: [
+    VideoEditorService,
+    VideoEditorEditResolver
+  ]
+})
+export class VideoEditorModule { }
index e59238ffe51f1fd6019ad81b06cdf10890b9fbef..6e8a64f46eb47e71d38f2009083d8c6951793612 100644 (file)
@@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
     playlist: false,
     download: true,
     update: true,
+    editor: true,
     blacklist: true,
     delete: true,
     report: true,
index 0c4d46714b543bee60f7cb79219b7d8d11cf30af..c6ffb1abdd23efbceab720ae7a6431359153531a 100644 (file)
   The video is being transcoded, it may not work properly.
 </div>
 
+<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
+  The video is being edited, it may not work properly.
+</div>
+
 <div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
   The video is being moved to an external server, it may not work properly.
 </div>
index a3d3fa6fbf2212e8011ebd825988c283b93c7041..79b56705ff746323c0c3769f24e651956ec24256 100644 (file)
@@ -14,6 +14,10 @@ export class VideoAlertComponent {
     return this.video && this.video.state.id === VideoState.TO_TRANSCODE
   }
 
+  isVideoToEdit () {
+    return this.video && this.video.state.id === VideoState.TO_EDIT
+  }
+
   isVideoTranscodingFailed () {
     return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
   }
index b5afc9c92fb4dc0d21d1768c0c1d4867250d1e7d..cd499845b542eb6ea8ffc2a6b90187c3813857a1 100644 (file)
@@ -143,6 +143,12 @@ const routes: Routes = [
     canActivateChild: [ MetaGuard ]
   },
 
+  {
+    path: 'video-editor',
+    loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
+    canActivateChild: [ MetaGuard ]
+  },
+
   // Matches /@:actorName
   {
     matcher: (url): UrlMatchResult => {
index 07a12c6f69003aec5c65024a192870a358e768ba..6b3a6c7730e70a6d36200a212683d69da75ff79a 100644 (file)
@@ -24,7 +24,7 @@ export abstract class FormReactive {
     this.formErrors = formErrors
     this.validationMessages = validationMessages
 
-    this.form.statusChanges.subscribe(async status => {
+    this.form.statusChanges.subscribe(async () => {
       // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
       await this.waitPendingCheck()
 
index 0fe50ac9bf7bc4be69be0907db5fc6bdb12e34b5..f67d5bb33529aab05cf120a36c4953800160526b 100644 (file)
@@ -30,7 +30,7 @@ export class FormValidatorService {
 
       if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
 
-      const defaultValue = defaultValues[name] || ''
+      const defaultValue = defaultValues[name] ?? ''
 
       if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
       else group[name] = [ defaultValue ]
index c57a4b32c291bf283a149e254deea6fb29ca0c3d..c89a7b0194f14c1b7d2a5505276a43a83ed13d02 100644 (file)
@@ -1,4 +1,5 @@
 <p-inputMask
   [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
-  mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
+  [ngClass]="{ 'border-disabled': disableBorder }"
+  mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
 ></p-inputMask>
index d2358c027617ad798505f5ff8ba47c3610d587eb..27d6fa17360e21f5008765d833c5586a14d6c37f 100644 (file)
@@ -1,10 +1,10 @@
 @use '_variables' as *;
+@use '_mixins' as *;
 
 p-inputmask {
   ::ng-deep input {
     width: 80px;
     font-size: 15px;
-    border: 0;
 
     &:focus-within,
     &:focus {
@@ -16,4 +16,16 @@ p-inputmask {
       opacity: 0.5;
     }
   }
+
+  &.border-disabled {
+    ::ng-deep input {
+      border: 0;
+    }
+  }
+
+  &:not(.border-disabled) {
+    ::ng-deep input {
+      @include peertube-input-text(80px);
+    }
+  }
 }
index 3fc705905e267b5b48f3de21b89310595f72dd90..79ca63673e298678dd75dbcdc822f219c0a19728 100644 (file)
@@ -18,6 +18,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
   @Input() maxTimestamp: number
   @Input() timestamp: number
   @Input() disabled = false
+  @Input() inputName: string
+  @Input() disableBorder = true
 
   @Output() inputBlur = new EventEmitter()
 
index c2a31828590323858ab18c5d30d1b86cd0ba7492..abbfc63f81f814a6ae92fca6fec2dacd50ef5682 100644 (file)
@@ -1,8 +1,8 @@
 import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
-import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core'
+import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
 import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
 import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-import { VideoCaption } from '@shared/models'
+import { VideoCaption, VideoState } from '@shared/models'
 import {
   Actor,
   DropdownAction,
@@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
   liveInfo?: boolean
   removeFiles?: boolean
   transcoding?: boolean
+  editor?: boolean
 }
 
 @Component({
@@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
     mute: true,
     liveInfo: false,
     removeFiles: false,
-    transcoding: false
+    transcoding: false,
+    editor: true
   }
   @Input() placement = 'left'
 
@@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
     private videoBlocklistService: VideoBlockService,
     private screenService: ScreenService,
     private videoService: VideoService,
-    private redundancyService: RedundancyService
+    private redundancyService: RedundancyService,
+    private serverService: ServerService
   ) { }
 
   get user () {
@@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
     return this.video.isUpdatableBy(this.user)
   }
 
+  isVideoEditable () {
+    return this.serverService.getHTMLConfig().videoEditor.enabled &&
+      this.video.state?.id === VideoState.PUBLISHED &&
+      this.video.isUpdatableBy(this.user)
+  }
+
   isVideoRemovable () {
     return this.video.isRemovableBy(this.user)
   }
@@ -329,6 +338,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
           iconName: 'edit',
           isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
         },
+        {
+          label: $localize`Editor`,
+          linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
+          iconName: 'film',
+          isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.editor && this.isVideoEditable()
+        },
         {
           label: $localize`Block`,
           handler: () => this.showBlockModal(),
index 847e401ed1837c8a325c83df209a4fd7c53119f4..7de9fc8e285dee3582e510e62f00857fb190f5d2 100644 (file)
@@ -195,6 +195,10 @@ export class VideoMiniatureComponent implements OnInit {
       return $localize`To import`
     }
 
+    if (video.state.id === VideoState.TO_EDIT) {
+      return $localize`To edit`
+    }
+
     return ''
   }
 
index 23be08f85e6b187c2085dab84c6872ceb015af5a..1e7fb9e5bf284bc26c3a5f3966aa2f7b5da896a8 100644 (file)
@@ -425,6 +425,10 @@ live:
       1440p: false
       2160p: false
 
+video_editor:
+  # Enable video edition by users (cut, add intro/outro, add watermark etc)
+  enabled: false
+
 import:
   # Add ability for your users to import remote videos (from YouTube, torrent...)
   videos:
index 675801caa0f938b53801c42d3306f27e6610b0f8..d1f18ecdeb78d3d6b99f082f0042561f1a1065b9 100644 (file)
@@ -433,6 +433,10 @@ live:
       1440p: false
       2160p: false
 
+video_editor:
+  # Enable video edition by users (cut, add intro/outro, add watermark etc)
+  enabled: false
+
 import:
   # Add ability for your users to import remote videos (from YouTube, torrent...)
   videos:
index d5f8299e06aa6f4d020d2c83158eb70c7e5c0da9..0f6d56f1aca027594841ba8cec57cc12ac518903 100644 (file)
@@ -37,6 +37,9 @@ signup:
 transcoding:
   enabled: false
 
+video_editor:
+  enabled: false
+
 live:
   rtmp:
     port: 1936
index 594439b623563a42b31f81bff9f32c71e19bfff5..3cd3ddba774f971a118358edc657615f634b81e1 100644 (file)
@@ -30,3 +30,6 @@ admin:
 
 transcoding:
   enabled: false
+
+video_editor:
+  enabled: false
index 1e6368bf76eba96d8880a5d8f8a4ab25c2ef35c0..6d8e5194561faae240d2fe190a77bdd11250c68d 100644 (file)
@@ -30,3 +30,6 @@ admin:
 
 transcoding:
   enabled: false
+
+video_editor:
+  enabled: false
index 97f18a7a0d1acd0ec60fad2a15a738bec806940a..5f2157fec6e6dc80b1a713ccfc31901eafb96ece 100644 (file)
@@ -30,3 +30,6 @@ admin:
 
 transcoding:
   enabled: false
+
+video_editor:
+  enabled: false
index 156da84d2935f4b38d22b0110387e5ea975e9ef7..9c43d2b2eac37b62471713a990d7b90d9f5f8ac5 100644 (file)
@@ -30,3 +30,6 @@ admin:
 
 transcoding:
   enabled: false
+
+video_editor:
+  enabled: false
index 461e1b4ba637e88b28a3330d396f088aed9ff647..99bf851436f827409a42b36d6cc49ed867ba8d80 100644 (file)
@@ -164,3 +164,6 @@ views:
 
     local_buffer_update_interval: '5 seconds'
     ip_view_expiration: '1 second'
+
+video_editor:
+  enabled: true
index c4b376431fff0cc6fecb66c446fd118af0013ea7..59fc84ad59e6723ea4ca056d9590c307d1415b31 100755 (executable)
@@ -1,6 +1,6 @@
 import { program } from 'commander'
 import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
-import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
+import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
 import { CONFIG } from '@server/initializers/config'
 import { addTranscodingJob } from '@server/lib/video'
 import { VideoState, VideoTranscodingPayload } from '@shared/models'
index 21667f5443b7cce078ef36bfa2ae034a848bb763..ef671c0aa230e9f7fd61e5a0f0e9afb87f2629de 100644 (file)
@@ -1,8 +1,8 @@
 import { program } from 'commander'
 import ffmpeg from 'fluent-ffmpeg'
 import { exit } from 'process'
-import { buildx264VODCommand, runCommand, TranscodeOptions } from '@server/helpers/ffmpeg-utils'
-import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/video-transcoding-profiles'
+import { buildVODCommand, runCommand, TranscodeVODOptions } from '@server/helpers/ffmpeg'
+import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
 
 program
   .arguments('<path>')
@@ -33,12 +33,12 @@ async function run (path: string, cmd: any) {
 
     resolution: +cmd.resolution,
     isPortraitMode: false
-  } as TranscodeOptions
+  } as TranscodeVODOptions
 
   let command = ffmpeg(options.inputPath)
                .output(options.outputPath)
 
-  command = await buildx264VODCommand(command, options)
+  command = await buildVODCommand(command, options)
 
   command.on('start', (cmdline) => {
     console.log(cmdline)
index 3859964702cedb542e023a380b025f45023117cc..bb7a0c2101eb8116464baa71a284ca0718b9424d 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -42,10 +42,7 @@ try {
 
 import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
 
-const errorMessage = checkConfig()
-if (errorMessage !== null) {
-  throw new Error(errorMessage)
-}
+checkConfig()
 
 // Trust our proxy (IP forwarding...)
 app.set('trust proxy', CONFIG.TRUST_PROXY)
index 4e3dd4d8082a73ca460a3e9020ac79b3095c18e0..821ed4ad32399f932ab1ce2403ea074fdf30f5bd 100644 (file)
@@ -256,6 +256,9 @@ function customConfig (): CustomConfig {
         }
       }
     },
+    videoEditor: {
+      enabled: CONFIG.VIDEO_EDITOR.ENABLED
+    },
     import: {
       videos: {
         concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
diff --git a/server/controllers/api/videos/editor.ts b/server/controllers/api/videos/editor.ts
new file mode 100644 (file)
index 0000000..61e2eb5
--- /dev/null
@@ -0,0 +1,120 @@
+import express from 'express'
+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, getTaskFile } from '@server/lib/video-editor'
+import {
+  HttpStatusCode,
+  VideoEditionTaskPayload,
+  VideoEditorCreateEdition,
+  VideoEditorTask,
+  VideoEditorTaskCut,
+  VideoEditorTaskIntro,
+  VideoEditorTaskOutro,
+  VideoEditorTaskWatermark,
+  VideoState
+} from '@shared/models'
+import { asyncMiddleware, authenticate, videosEditorAddEditionValidator } from '../../../middlewares'
+
+const editorRouter = express.Router()
+
+const tasksFiles = createAnyReqFiles(
+  MIMETYPES.VIDEO.MIMETYPE_EXT,
+  CONFIG.STORAGE.TMP_DIR,
+  (req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
+    const body = req.body as VideoEditorCreateEdition
+
+    // Fetch array element
+    const matches = file.fieldname.match(/tasks\[(\d+)\]/)
+    if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
+
+    const indice = parseInt(matches[1])
+    const task = body.tasks[indice]
+
+    if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
+
+    if (
+      [ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
+      file.fieldname === buildTaskFileFieldname(indice)
+    ) {
+      return cb(null, true)
+    }
+
+    return cb(null, false)
+  }
+)
+
+editorRouter.post('/:videoId/editor/edit',
+  authenticate,
+  tasksFiles,
+  asyncMiddleware(videosEditorAddEditionValidator),
+  asyncMiddleware(createEditionTasks)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  editorRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function createEditionTasks (req: express.Request, res: express.Response) {
+  const files = req.files as Express.Multer.File[]
+  const body = req.body as VideoEditorCreateEdition
+  const video = res.locals.videoAll
+
+  video.state = VideoState.TO_EDIT
+  await video.save()
+
+  const payload = {
+    videoUUID: video.uuid,
+    tasks: body.tasks.map((t, i) => buildTaskPayload(t, i, files))
+  }
+
+  JobQueue.Instance.createJob({ type: 'video-edition', payload })
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+const taskPayloadBuilders: {
+  [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => VideoEditionTaskPayload
+} = {
+  'add-intro': buildIntroOutroTask,
+  'add-outro': buildIntroOutroTask,
+  'cut': buildCutTask,
+  'add-watermark': buildWatermarkTask
+}
+
+function buildTaskPayload (task: VideoEditorTask, indice: number, files: Express.Multer.File[]): VideoEditionTaskPayload {
+  return taskPayloadBuilders[task.name](task, indice, files)
+}
+
+function buildIntroOutroTask (task: VideoEditorTaskIntro | VideoEditorTaskOutro, indice: number, files: Express.Multer.File[]) {
+  return {
+    name: task.name,
+    options: {
+      file: getTaskFile(files, indice).path
+    }
+  }
+}
+
+function buildCutTask (task: VideoEditorTaskCut) {
+  return {
+    name: task.name,
+    options: {
+      start: task.options.start,
+      end: task.options.end
+    }
+  }
+}
+
+function buildWatermarkTask (task: VideoEditorTaskWatermark, indice: number, files: Express.Multer.File[]) {
+  return {
+    name: task.name,
+    options: {
+      file: getTaskFile(files, indice).path
+    }
+  }
+}
index 61a030ba144e18835557279dc05678f50dafbedc..a5ae07d95a2e68e5feae5c75ac5d73a02cfee875 100644 (file)
@@ -35,6 +35,7 @@ import { VideoModel } from '../../../models/video/video'
 import { blacklistRouter } from './blacklist'
 import { videoCaptionsRouter } from './captions'
 import { videoCommentRouter } from './comment'
+import { editorRouter } from './editor'
 import { filesRouter } from './files'
 import { videoImportsRouter } from './import'
 import { liveRouter } from './live'
@@ -51,6 +52,7 @@ const videosRouter = express.Router()
 videosRouter.use('/', blacklistRouter)
 videosRouter.use('/', rateVideoRouter)
 videosRouter.use('/', videoCommentRouter)
+videosRouter.use('/', editorRouter)
 videosRouter.use('/', videoCaptionsRouter)
 videosRouter.use('/', videoImportsRouter)
 videosRouter.use('/', ownershipVideoRouter)
index fba4545c2a6b849069c8b57c0d23622e5b556fe2..da3ea3c9c0abce4bb238933fbeddad2771e8399c 100644 (file)
@@ -1,5 +1,5 @@
 import express from 'express'
-import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
+import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
 import { logger, loggerTagsFactory } from '@server/helpers/logger'
 import { addTranscodingJob } from '@server/lib/video'
 import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
@@ -29,7 +29,7 @@ async function createTranscoding (req: express.Request, res: express.Response) {
 
   const body: VideoTranscodingCreate = req.body
 
-  const { resolution: maxResolution, isPortraitMode, audioStream } = await video.getMaxQualityFileInfo()
+  const { resolution: maxResolution, isPortraitMode, audioStream } = await video.probeMaxQualityFile()
   const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
 
   video.state = VideoState.TO_TRANSCODE
index fd90d99154b7ba3a287b0728d9ed84f9b4b3e841..3c026ad1f07393589426ce5e024807b36d38143e 100644 (file)
@@ -24,7 +24,7 @@ import { HttpStatusCode, VideoCreate, VideoResolution, VideoState } from '@share
 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
 import { createReqFiles } from '../../../helpers/express-utils'
-import { ffprobePromise, getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
+import { ffprobePromise, buildFileMetadata, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
 import { logger, loggerTagsFactory } from '../../../helpers/logger'
 import { CONFIG } from '../../../initializers/config'
 import { MIMETYPES } from '../../../initializers/constants'
@@ -246,7 +246,7 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
     extname: getLowercaseExtension(videoPhysicalFile.filename),
     size: videoPhysicalFile.size,
     videoStreamingPlaylistId: null,
-    metadata: await getMetadataFromFile(videoPhysicalFile.path)
+    metadata: await buildFileMetadata(videoPhysicalFile.path)
   })
 
   const probe = await ffprobePromise(videoPhysicalFile.path)
@@ -254,8 +254,8 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
   if (await isAudioFile(videoPhysicalFile.path, probe)) {
     videoFile.resolution = VideoResolution.H_NOVIDEO
   } else {
-    videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path, probe)
-    videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path, probe)).resolution
+    videoFile.fps = await getVideoStreamFPS(videoPhysicalFile.path, probe)
+    videoFile.resolution = (await getVideoStreamDimensionsInfo(videoPhysicalFile.path, probe)).resolution
   }
 
   videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
index 4fb0b7c703653124e6232f32abbdfb35159cb794..89f5a226212d5d7f87f6efc5e487aa7d54a1deca 100644 (file)
@@ -1,4 +1,5 @@
 
+import { UploadFilesForCheck } from 'express'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { isFileValid } from './misc'
 
@@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
   .map(v => v.replace('.', ''))
   .join('|')
 const imageMimeTypesRegex = `image/(${imageMimeTypes})`
-function isActorImageFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], fieldname: string) {
-  return isFileValid(files, imageMimeTypesRegex, fieldname, CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max)
+
+function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
+  return isFileValid({
+    files,
+    mimeTypeRegex: imageMimeTypesRegex,
+    field: fieldname,
+    maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
+  })
 }
 
 // ---------------------------------------------------------------------------
index 81a60ee6601dce800fd93c7213643ccd5802dcc2..c80c861932716e395574c3d76736bbd6915c85fd 100644 (file)
@@ -61,75 +61,43 @@ function isIntOrNull (value: any) {
 
 // ---------------------------------------------------------------------------
 
-function isFileFieldValid (
-  files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
-  field: string,
-  optional = false
-) {
-  // Should have files
-  if (!files) return optional
-  if (isArray(files)) return optional
+function isFileValid (options: {
+  files: UploadFilesForCheck
 
-  // Should have a file
-  const fileArray = files[field]
-  if (!fileArray || fileArray.length === 0) {
-    return optional
-  }
+  maxSize: number | null
+  mimeTypeRegex: string | null
 
-  // The file should exist
-  const file = fileArray[0]
-  if (!file || !file.originalname) return false
-  return file
-}
+  field?: string
 
-function isFileMimeTypeValid (
-  files: UploadFilesForCheck,
-  mimeTypeRegex: string,
-  field: string,
-  optional = false
-) {
-  // Should have files
-  if (!files) return optional
-  if (isArray(files)) return optional
+  optional?: boolean // Default false
+}) {
+  const { files, mimeTypeRegex, field, maxSize, optional = false } = options
 
-  // Should have a file
-  const fileArray = files[field]
-  if (!fileArray || fileArray.length === 0) {
-    return optional
-  }
-
-  // The file should exist
-  const file = fileArray[0]
-  if (!file || !file.originalname) return false
-
-  return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
-}
-
-function isFileValid (
-  files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
-  mimeTypeRegex: string,
-  field: string,
-  maxSize: number | null,
-  optional = false
-) {
   // Should have files
   if (!files) return optional
-  if (isArray(files)) return optional
 
-  // Should have a file
-  const fileArray = files[field]
-  if (!fileArray || fileArray.length === 0) {
+  const fileArray = isArray(files)
+    ? files
+    : files[field]
+
+  if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
     return optional
   }
 
-  // The file should exist
+  // The file exists
   const file = fileArray[0]
   if (!file || !file.originalname) return false
 
   // Check size
   if ((maxSize !== null) && file.size > maxSize) return false
 
-  return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
+  if (mimeTypeRegex === null) return true
+
+  return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
+}
+
+function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
+  return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
 }
 
 // ---------------------------------------------------------------------------
@@ -204,7 +172,6 @@ export {
   areUUIDsValid,
   toArray,
   toIntArray,
-  isFileFieldValid,
-  isFileMimeTypeValid,
-  isFileValid
+  isFileValid,
+  checkMimetypeRegex
 }
index 4cc7dcaf4ab62845f2ac6de572d343622a57ca7f..59ba005fe3f4b8d8feb152b9e210b559c72feac5 100644 (file)
@@ -1,5 +1,6 @@
-import { getFileSize } from '@shared/extra-utils'
+import { UploadFilesForCheck } from 'express'
 import { readFile } from 'fs-extra'
+import { getFileSize } from '@shared/extra-utils'
 import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
 import { exists, isFileValid } from './misc'
 
@@ -11,8 +12,13 @@ const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT
                                 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
                                 .map(m => `(${m})`)
                                 .join('|')
-function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
-  return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
+function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
+  return isFileValid({
+    files,
+    mimeTypeRegex: videoCaptionTypesRegex,
+    field,
+    maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
+  })
 }
 
 async function isVTTFileValid (filePath: string) {
diff --git a/server/helpers/custom-validators/video-editor.ts b/server/helpers/custom-validators/video-editor.ts
new file mode 100644 (file)
index 0000000..0923867
--- /dev/null
@@ -0,0 +1,52 @@
+import validator from 'validator'
+import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
+import { buildTaskFileFieldname } from '@server/lib/video-editor'
+import { VideoEditorTask } from '@shared/models'
+import { isArray } from './misc'
+import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos'
+
+function isValidEditorTasksArray (tasks: any) {
+  if (!isArray(tasks)) return false
+
+  return tasks.length >= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.min &&
+    tasks.length <= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.max
+}
+
+function isEditorCutTaskValid (task: VideoEditorTask) {
+  if (task.name !== 'cut') return false
+  if (!task.options) return false
+
+  const { start, end } = task.options
+  if (!start && !end) return false
+
+  if (start && !validator.isInt(start + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
+  if (end && !validator.isInt(end + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
+
+  if (!start || !end) return true
+
+  return parseInt(start + '') < parseInt(end + '')
+}
+
+function isEditorTaskAddIntroOutroValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
+  const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
+
+  return (task.name === 'add-intro' || task.name === 'add-outro') &&
+    file && isVideoFileMimeTypeValid([ file ], null)
+}
+
+function isEditorTaskAddWatermarkValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
+  const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
+
+  return task.name === 'add-watermark' &&
+    file && isVideoImageValid([ file ], null, true)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isValidEditorTasksArray,
+
+  isEditorCutTaskValid,
+  isEditorTaskAddIntroOutroValid,
+  isEditorTaskAddWatermarkValid
+}
index dbf6a3504a9aa8e9b67009e488eb207fa3a0840b..af93aea56062a86ca17b1e441c8b9593de123632 100644 (file)
@@ -1,4 +1,5 @@
 import 'multer'
+import { UploadFilesForCheck } from 'express'
 import validator from 'validator'
 import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
 import { exists, isFileValid } from './misc'
@@ -25,8 +26,14 @@ const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
                                       .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
                                       .map(m => `(${m})`)
                                       .join('|')
-function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
-  return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
+function isVideoImportTorrentFile (files: UploadFilesForCheck) {
+  return isFileValid({
+    files,
+    mimeTypeRegex: videoTorrentImportRegex,
+    field: 'torrentfile',
+    maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
+    optional: true
+  })
 }
 
 // ---------------------------------------------------------------------------
index e526c428405df547a75feb139cc08bb44f2ff58c..ca5f70fdc2e8574dc52c7a5207afc14fe039eb74 100644 (file)
@@ -13,7 +13,7 @@ import {
   VIDEO_RATE_TYPES,
   VIDEO_STATES
 } from '../../initializers/constants'
-import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc'
+import { exists, isArray, isDateValid, isFileValid } from './misc'
 
 const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
 
@@ -66,7 +66,7 @@ function isVideoTagValid (tag: string) {
   return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
 }
 
-function isVideoTagsValid (tags: string[]) {
+function areVideoTagsValid (tags: string[]) {
   return tags === null || (
     isArray(tags) &&
     validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) &&
@@ -86,8 +86,13 @@ function isVideoFileExtnameValid (value: string) {
   return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
 }
 
-function isVideoFileMimeTypeValid (files: UploadFilesForCheck) {
-  return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile')
+function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
+  return isFileValid({
+    files,
+    mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
+    field,
+    maxSize: null
+  })
 }
 
 const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
                                           .join('|')
 const videoImageTypesRegex = `image/(${videoImageTypes})`
 
-function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
-  return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true)
+function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
+  return isFileValid({
+    files,
+    mimeTypeRegex: videoImageTypesRegex,
+    field,
+    maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
+    optional
+  })
 }
 
 function isVideoPrivacyValid (value: number) {
@@ -144,7 +155,7 @@ export {
   isVideoDescriptionValid,
   isVideoFileInfoHashValid,
   isVideoNameValid,
-  isVideoTagsValid,
+  areVideoTagsValid,
   isVideoFPSResolutionValid,
   isScheduleVideoUpdatePrivacyValid,
   isVideoOriginallyPublishedAtValid,
@@ -160,7 +171,7 @@ export {
   isVideoPrivacyValid,
   isVideoFileResolutionValid,
   isVideoFileSizeValid,
-  isVideoImage,
+  isVideoImageValid,
   isVideoSupportValid,
   isVideoFilterValid
 }
index 780fd6345e850df4735f70d066c3f65616e8d926..08f77966f29293a503e5589c90af571a612a9059 100644 (file)
@@ -1,9 +1,9 @@
 import express, { RequestHandler } from 'express'
 import multer, { diskStorage } from 'multer'
+import { getLowercaseExtension } from '@shared/core-utils'
 import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
 import { CONFIG } from '../initializers/config'
 import { REMOTE_SCHEME } from '../initializers/constants'
-import { getLowercaseExtension } from '@shared/core-utils'
 import { isArray } from './custom-validators/misc'
 import { logger } from './logger'
 import { deleteFileAndCatch, generateRandomString } from './utils'
@@ -75,29 +75,8 @@ function createReqFiles (
       cb(null, destinations[file.fieldname])
     },
 
-    filename: async (req, file, cb) => {
-      let extension: string
-      const fileExtension = getLowercaseExtension(file.originalname)
-      const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
-
-      // Take the file extension if we don't understand the mime type
-      if (!extensionFromMimetype) {
-        extension = fileExtension
-      } else {
-        // Take the first available extension for this mimetype
-        extension = extensionFromMimetype
-      }
-
-      let randomString = ''
-
-      try {
-        randomString = await generateRandomString(16)
-      } catch (err) {
-        logger.error('Cannot generate random string for file name.', { err })
-        randomString = 'fake-random-string'
-      }
-
-      cb(null, randomString + extension)
+    filename: (req, file, cb) => {
+      return generateReqFilename(file, mimeTypes, cb)
     }
   })
 
@@ -112,6 +91,24 @@ function createReqFiles (
   return multer({ storage }).fields(fields)
 }
 
+function createAnyReqFiles (
+  mimeTypes: { [id: string]: string | string[] },
+  destinationDirectory: string,
+  fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void
+): RequestHandler {
+  const storage = diskStorage({
+    destination: (req, file, cb) => {
+      cb(null, destinationDirectory)
+    },
+
+    filename: (req, file, cb) => {
+      return generateReqFilename(file, mimeTypes, cb)
+    }
+  })
+
+  return multer({ storage, fileFilter }).any()
+}
+
 function isUserAbleToSearchRemoteURI (res: express.Response) {
   const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
 
@@ -128,9 +125,41 @@ function getCountVideos (req: express.Request) {
 export {
   buildNSFWFilter,
   getHostWithPort,
+  createAnyReqFiles,
   isUserAbleToSearchRemoteURI,
   badRequest,
   createReqFiles,
   cleanUpReqFiles,
   getCountVideos
 }
+
+// ---------------------------------------------------------------------------
+
+async function generateReqFilename (
+  file: Express.Multer.File,
+  mimeTypes: { [id: string]: string | string[] },
+  cb: (err: Error, name: string) => void
+) {
+  let extension: string
+  const fileExtension = getLowercaseExtension(file.originalname)
+  const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
+
+  // Take the file extension if we don't understand the mime type
+  if (!extensionFromMimetype) {
+    extension = fileExtension
+  } else {
+    // Take the first available extension for this mimetype
+    extension = extensionFromMimetype
+  }
+
+  let randomString = ''
+
+  try {
+    randomString = await generateRandomString(16)
+  } catch (err) {
+    logger.error('Cannot generate random string for file name.', { err })
+    randomString = 'fake-random-string'
+  }
+
+  cb(null, randomString + extension)
+}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
deleted file mode 100644 (file)
index 78ee5fa..0000000
+++ /dev/null
@@ -1,781 +0,0 @@
-import { Job } from 'bull'
-import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
-import { readFile, remove, writeFile } from 'fs-extra'
-import { dirname, join } from 'path'
-import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
-import { pick } from '@shared/core-utils'
-import {
-  AvailableEncoders,
-  EncoderOptions,
-  EncoderOptionsBuilder,
-  EncoderOptionsBuilderParams,
-  EncoderProfile,
-  VideoResolution
-} from '../../shared/models/videos'
-import { CONFIG } from '../initializers/config'
-import { execPromise, promisify0 } from './core-utils'
-import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
-import { processImage } from './image-utils'
-import { logger, loggerTagsFactory } from './logger'
-
-const lTags = loggerTagsFactory('ffmpeg')
-
-/**
- *
- * Functions that run transcoding/muxing ffmpeg processes
- * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
- *
- */
-
-// ---------------------------------------------------------------------------
-// Encoder options
-// ---------------------------------------------------------------------------
-
-type StreamType = 'audio' | 'video'
-
-// ---------------------------------------------------------------------------
-// Encoders support
-// ---------------------------------------------------------------------------
-
-// Detect supported encoders by ffmpeg
-let supportedEncoders: Map<string, boolean>
-async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
-  if (supportedEncoders !== undefined) {
-    return supportedEncoders
-  }
-
-  const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
-  const availableFFmpegEncoders = await getAvailableEncodersPromise()
-
-  const searchEncoders = new Set<string>()
-  for (const type of [ 'live', 'vod' ]) {
-    for (const streamType of [ 'audio', 'video' ]) {
-      for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
-        searchEncoders.add(encoder)
-      }
-    }
-  }
-
-  supportedEncoders = new Map<string, boolean>()
-
-  for (const searchEncoder of searchEncoders) {
-    supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
-  }
-
-  logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
-
-  return supportedEncoders
-}
-
-function resetSupportedEncoders () {
-  supportedEncoders = undefined
-}
-
-// ---------------------------------------------------------------------------
-// Image manipulation
-// ---------------------------------------------------------------------------
-
-function convertWebPToJPG (path: string, destination: string): Promise<void> {
-  const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
-    .output(destination)
-
-  return runCommand({ command, silent: true })
-}
-
-function processGIF (
-  path: string,
-  destination: string,
-  newSize: { width: number, height: number }
-): Promise<void> {
-  const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
-    .fps(20)
-    .size(`${newSize.width}x${newSize.height}`)
-    .output(destination)
-
-  return runCommand({ command })
-}
-
-async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
-  const pendingImageName = 'pending-' + imageName
-
-  const options = {
-    filename: pendingImageName,
-    count: 1,
-    folder
-  }
-
-  const pendingImagePath = join(folder, pendingImageName)
-
-  try {
-    await new Promise<string>((res, rej) => {
-      ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
-        .on('error', rej)
-        .on('end', () => res(imageName))
-        .thumbnail(options)
-    })
-
-    const destination = join(folder, imageName)
-    await processImage(pendingImagePath, destination, size)
-  } catch (err) {
-    logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
-
-    try {
-      await remove(pendingImagePath)
-    } catch (err) {
-      logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
-    }
-  }
-}
-
-// ---------------------------------------------------------------------------
-// Transcode meta function
-// ---------------------------------------------------------------------------
-
-type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
-
-interface BaseTranscodeOptions {
-  type: TranscodeOptionsType
-
-  inputPath: string
-  outputPath: string
-
-  availableEncoders: AvailableEncoders
-  profile: string
-
-  resolution: number
-
-  isPortraitMode?: boolean
-
-  job?: Job
-}
-
-interface HLSTranscodeOptions extends BaseTranscodeOptions {
-  type: 'hls'
-  copyCodecs: boolean
-  hlsPlaylist: {
-    videoFilename: string
-  }
-}
-
-interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
-  type: 'hls-from-ts'
-
-  isAAC: boolean
-
-  hlsPlaylist: {
-    videoFilename: string
-  }
-}
-
-interface QuickTranscodeOptions extends BaseTranscodeOptions {
-  type: 'quick-transcode'
-}
-
-interface VideoTranscodeOptions extends BaseTranscodeOptions {
-  type: 'video'
-}
-
-interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
-  type: 'merge-audio'
-  audioPath: string
-}
-
-interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
-  type: 'only-audio'
-}
-
-type TranscodeOptions =
-  HLSTranscodeOptions
-  | HLSFromTSTranscodeOptions
-  | VideoTranscodeOptions
-  | MergeAudioTranscodeOptions
-  | OnlyAudioTranscodeOptions
-  | QuickTranscodeOptions
-
-const builders: {
-  [ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand
-} = {
-  'quick-transcode': buildQuickTranscodeCommand,
-  'hls': buildHLSVODCommand,
-  'hls-from-ts': buildHLSVODFromTSCommand,
-  'merge-audio': buildAudioMergeCommand,
-  'only-audio': buildOnlyAudioCommand,
-  'video': buildx264VODCommand
-}
-
-async function transcode (options: TranscodeOptions) {
-  logger.debug('Will run transcode.', { options, ...lTags() })
-
-  let command = getFFmpeg(options.inputPath, 'vod')
-    .output(options.outputPath)
-
-  command = await builders[options.type](command, options)
-
-  await runCommand({ command, job: options.job })
-
-  await fixHLSPlaylistIfNeeded(options)
-}
-
-// ---------------------------------------------------------------------------
-// Live muxing/transcoding functions
-// ---------------------------------------------------------------------------
-
-async function getLiveTranscodingCommand (options: {
-  inputUrl: string
-
-  outPath: string
-  masterPlaylistName: string
-
-  resolutions: number[]
-
-  // Input information
-  fps: number
-  bitrate: number
-  ratio: number
-
-  availableEncoders: AvailableEncoders
-  profile: string
-}) {
-  const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
-
-  const command = getFFmpeg(inputUrl, 'live')
-
-  const varStreamMap: string[] = []
-
-  const complexFilter: FilterSpecification[] = [
-    {
-      inputs: '[v:0]',
-      filter: 'split',
-      options: resolutions.length,
-      outputs: resolutions.map(r => `vtemp${r}`)
-    }
-  ]
-
-  command.outputOption('-sc_threshold 0')
-
-  addDefaultEncoderGlobalParams({ command })
-
-  for (let i = 0; i < resolutions.length; i++) {
-    const resolution = resolutions[i]
-    const resolutionFPS = computeFPS(fps, resolution)
-
-    const baseEncoderBuilderParams = {
-      input: inputUrl,
-
-      availableEncoders,
-      profile,
-
-      inputBitrate: bitrate,
-      inputRatio: ratio,
-
-      resolution,
-      fps: resolutionFPS,
-
-      streamNum: i,
-      videoType: 'live' as 'live'
-    }
-
-    {
-      const streamType: StreamType = 'video'
-      const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
-      if (!builderResult) {
-        throw new Error('No available live video encoder found')
-      }
-
-      command.outputOption(`-map [vout${resolution}]`)
-
-      addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
-
-      logger.debug(
-        'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
-        { builderResult, fps: resolutionFPS, resolution, ...lTags() }
-      )
-
-      command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
-      applyEncoderOptions(command, builderResult.result)
-
-      complexFilter.push({
-        inputs: `vtemp${resolution}`,
-        filter: getScaleFilter(builderResult.result),
-        options: `w=-2:h=${resolution}`,
-        outputs: `vout${resolution}`
-      })
-    }
-
-    {
-      const streamType: StreamType = 'audio'
-      const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
-      if (!builderResult) {
-        throw new Error('No available live audio encoder found')
-      }
-
-      command.outputOption('-map a:0')
-
-      addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
-
-      logger.debug(
-        'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
-        { builderResult, fps: resolutionFPS, resolution, ...lTags() }
-      )
-
-      command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
-      applyEncoderOptions(command, builderResult.result)
-    }
-
-    varStreamMap.push(`v:${i},a:${i}`)
-  }
-
-  command.complexFilter(complexFilter)
-
-  addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
-
-  command.outputOption('-var_stream_map', varStreamMap.join(' '))
-
-  return command
-}
-
-function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
-  const command = getFFmpeg(inputUrl, 'live')
-
-  command.outputOption('-c:v copy')
-  command.outputOption('-c:a copy')
-  command.outputOption('-map 0:a?')
-  command.outputOption('-map 0:v?')
-
-  addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
-
-  return command
-}
-
-function buildStreamSuffix (base: string, streamNum?: number) {
-  if (streamNum !== undefined) {
-    return `${base}:${streamNum}`
-  }
-
-  return base
-}
-
-// ---------------------------------------------------------------------------
-// Default options
-// ---------------------------------------------------------------------------
-
-function addDefaultEncoderGlobalParams (options: {
-  command: FfmpegCommand
-}) {
-  const { command } = options
-
-  // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
-  command.outputOption('-max_muxing_queue_size 1024')
-         // strip all metadata
-         .outputOption('-map_metadata -1')
-         // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
-         .outputOption('-pix_fmt yuv420p')
-}
-
-function addDefaultEncoderParams (options: {
-  command: FfmpegCommand
-  encoder: 'libx264' | string
-  streamNum?: number
-  fps?: number
-}) {
-  const { command, encoder, fps, streamNum } = options
-
-  if (encoder === 'libx264') {
-    // 3.1 is the minimal resource allocation for our highest supported resolution
-    command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
-
-    if (fps) {
-      // Keyframe interval of 2 seconds for faster seeking and resolution switching.
-      // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
-      // https://superuser.com/a/908325
-      command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
-    }
-  }
-}
-
-function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
-  command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
-  command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
-  command.outputOption('-hls_flags delete_segments+independent_segments')
-  command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
-  command.outputOption('-master_pl_name ' + masterPlaylistName)
-  command.outputOption(`-f hls`)
-
-  command.output(join(outPath, '%v.m3u8'))
-}
-
-// ---------------------------------------------------------------------------
-// Transcode VOD command builders
-// ---------------------------------------------------------------------------
-
-async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) {
-  let fps = await getVideoFileFPS(options.inputPath)
-  fps = computeFPS(fps, options.resolution)
-
-  let scaleFilterValue: string
-
-  if (options.resolution !== undefined) {
-    scaleFilterValue = options.isPortraitMode === true
-      ? `w=${options.resolution}:h=-2`
-      : `w=-2:h=${options.resolution}`
-  }
-
-  command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue })
-
-  return command
-}
-
-async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
-  command = command.loop(undefined)
-
-  const scaleFilterValue = getScaleCleanerValue()
-  command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
-
-  command.outputOption('-preset:v veryfast')
-
-  command = command.input(options.audioPath)
-                   .outputOption('-tune stillimage')
-                   .outputOption('-shortest')
-
-  return command
-}
-
-function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
-  command = presetOnlyAudio(command)
-
-  return command
-}
-
-function buildQuickTranscodeCommand (command: FfmpegCommand) {
-  command = presetCopy(command)
-
-  command = command.outputOption('-map_metadata -1') // strip all metadata
-                   .outputOption('-movflags faststart')
-
-  return command
-}
-
-function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
-  return command.outputOption('-hls_time 4')
-                .outputOption('-hls_list_size 0')
-                .outputOption('-hls_playlist_type vod')
-                .outputOption('-hls_segment_filename ' + outputPath)
-                .outputOption('-hls_segment_type fmp4')
-                .outputOption('-f hls')
-                .outputOption('-hls_flags single_file')
-}
-
-async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
-  const videoPath = getHLSVideoPath(options)
-
-  if (options.copyCodecs) command = presetCopy(command)
-  else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
-  else command = await buildx264VODCommand(command, options)
-
-  addCommonHLSVODCommandOptions(command, videoPath)
-
-  return command
-}
-
-function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
-  const videoPath = getHLSVideoPath(options)
-
-  command.outputOption('-c copy')
-
-  if (options.isAAC) {
-    // Required for example when copying an AAC stream from an MPEG-TS
-    // Since it's a bitstream filter, we don't need to reencode the audio
-    command.outputOption('-bsf:a aac_adtstoasc')
-  }
-
-  addCommonHLSVODCommandOptions(command, videoPath)
-
-  return command
-}
-
-async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
-  if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
-
-  const fileContent = await readFile(options.outputPath)
-
-  const videoFileName = options.hlsPlaylist.videoFilename
-  const videoFilePath = getHLSVideoPath(options)
-
-  // Fix wrong mapping with some ffmpeg versions
-  const newContent = fileContent.toString()
-                                .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
-
-  await writeFile(options.outputPath, newContent)
-}
-
-function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
-  return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
-}
-
-// ---------------------------------------------------------------------------
-// Transcoding presets
-// ---------------------------------------------------------------------------
-
-// Run encoder builder depending on available encoders
-// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
-// If the default one does not exist, check the next encoder
-async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
-  streamType: 'video' | 'audio'
-  input: string
-
-  availableEncoders: AvailableEncoders
-  profile: string
-
-  videoType: 'vod' | 'live'
-}) {
-  const { availableEncoders, profile, streamType, videoType } = options
-
-  const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
-  const encoders = availableEncoders.available[videoType]
-
-  for (const encoder of encodersToTry) {
-    if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
-      logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
-      continue
-    }
-
-    if (!encoders[encoder]) {
-      logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
-      continue
-    }
-
-    // An object containing available profiles for this encoder
-    const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
-    let builder = builderProfiles[profile]
-
-    if (!builder) {
-      logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
-      builder = builderProfiles.default
-
-      if (!builder) {
-        logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
-        continue
-      }
-    }
-
-    const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]))
-
-    return {
-      result,
-
-      // If we don't have output options, then copy the input stream
-      encoder: result.copy === true
-        ? 'copy'
-        : encoder
-    }
-  }
-
-  return null
-}
-
-async function presetVideo (options: {
-  command: FfmpegCommand
-  input: string
-  transcodeOptions: TranscodeOptions
-  fps?: number
-  scaleFilterValue?: string
-}) {
-  const { command, input, transcodeOptions, fps, scaleFilterValue } = options
-
-  let localCommand = command
-    .format('mp4')
-    .outputOption('-movflags faststart')
-
-  addDefaultEncoderGlobalParams({ command })
-
-  const probe = await ffprobePromise(input)
-
-  // Audio encoder
-  const parsedAudio = await getAudioStream(input, probe)
-  const bitrate = await getVideoFileBitrate(input, probe)
-  const { ratio } = await getVideoFileResolution(input, probe)
-
-  let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
-
-  if (!parsedAudio.audioStream) {
-    localCommand = localCommand.noAudio()
-    streamsToProcess = [ 'video' ]
-  }
-
-  for (const streamType of streamsToProcess) {
-    const { profile, resolution, availableEncoders } = transcodeOptions
-
-    const builderResult = await getEncoderBuilderResult({
-      streamType,
-      input,
-      resolution,
-      availableEncoders,
-      profile,
-      fps,
-      inputBitrate: bitrate,
-      inputRatio: ratio,
-      videoType: 'vod' as 'vod'
-    })
-
-    if (!builderResult) {
-      throw new Error('No available encoder found for stream ' + streamType)
-    }
-
-    logger.debug(
-      'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
-      builderResult.encoder, streamType, input, profile,
-      { builderResult, resolution, fps, ...lTags() }
-    )
-
-    if (streamType === 'video') {
-      localCommand.videoCodec(builderResult.encoder)
-
-      if (scaleFilterValue) {
-        localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
-      }
-    } else if (streamType === 'audio') {
-      localCommand.audioCodec(builderResult.encoder)
-    }
-
-    applyEncoderOptions(localCommand, builderResult.result)
-    addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
-  }
-
-  return localCommand
-}
-
-function presetCopy (command: FfmpegCommand): FfmpegCommand {
-  return command
-    .format('mp4')
-    .videoCodec('copy')
-    .audioCodec('copy')
-}
-
-function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
-  return command
-    .format('mp4')
-    .audioCodec('copy')
-    .noVideo()
-}
-
-function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
-  return command
-    .inputOptions(options.inputOptions ?? [])
-    .outputOptions(options.outputOptions ?? [])
-}
-
-function getScaleFilter (options: EncoderOptions): string {
-  if (options.scaleFilter) return options.scaleFilter.name
-
-  return 'scale'
-}
-
-// ---------------------------------------------------------------------------
-// Utils
-// ---------------------------------------------------------------------------
-
-function getFFmpeg (input: string, type: 'live' | 'vod') {
-  // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
-  const command = ffmpeg(input, {
-    niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
-    cwd: CONFIG.STORAGE.TMP_DIR
-  })
-
-  const threads = type === 'live'
-    ? CONFIG.LIVE.TRANSCODING.THREADS
-    : CONFIG.TRANSCODING.THREADS
-
-  if (threads > 0) {
-    // If we don't set any threads ffmpeg will chose automatically
-    command.outputOption('-threads ' + threads)
-  }
-
-  return command
-}
-
-function getFFmpegVersion () {
-  return new Promise<string>((res, rej) => {
-    (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
-      if (err) return rej(err)
-      if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
-
-      return execPromise(`${ffmpegPath} -version`)
-        .then(stdout => {
-          const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
-          if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
-
-          // Fix ffmpeg version that does not include patch version (4.4 for example)
-          let version = parsed[1]
-          if (version.match(/^\d+\.\d+$/)) {
-            version += '.0'
-          }
-
-          return res(version)
-        })
-        .catch(err => rej(err))
-    })
-  })
-}
-
-async function runCommand (options: {
-  command: FfmpegCommand
-  silent?: boolean // false
-  job?: Job
-}) {
-  const { command, silent = false, job } = options
-
-  return new Promise<void>((res, rej) => {
-    let shellCommand: string
-
-    command.on('start', cmdline => { shellCommand = cmdline })
-
-    command.on('error', (err, stdout, stderr) => {
-      if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
-
-      rej(err)
-    })
-
-    command.on('end', (stdout, stderr) => {
-      logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
-
-      res()
-    })
-
-    if (job) {
-      command.on('progress', progress => {
-        if (!progress.percent) return
-
-        job.progress(Math.round(progress.percent))
-          .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
-      })
-    }
-
-    command.run()
-  })
-}
-
-// Avoid "height not divisible by 2" error
-function getScaleCleanerValue () {
-  return 'trunc(iw/2)*2:trunc(ih/2)*2'
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  getLiveTranscodingCommand,
-  getLiveMuxingCommand,
-  buildStreamSuffix,
-  convertWebPToJPG,
-  processGIF,
-  generateImageFromVideoFile,
-  TranscodeOptions,
-  TranscodeOptionsType,
-  transcode,
-  runCommand,
-  getFFmpegVersion,
-
-  resetSupportedEncoders,
-
-  // builders
-  buildx264VODCommand
-}
diff --git a/server/helpers/ffmpeg/ffmpeg-commons.ts b/server/helpers/ffmpeg/ffmpeg-commons.ts
new file mode 100644 (file)
index 0000000..ee33888
--- /dev/null
@@ -0,0 +1,114 @@
+import { Job } from 'bull'
+import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
+import { execPromise } from '@server/helpers/core-utils'
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { CONFIG } from '@server/initializers/config'
+import { FFMPEG_NICE } from '@server/initializers/constants'
+import { EncoderOptions } from '@shared/models'
+
+const lTags = loggerTagsFactory('ffmpeg')
+
+type StreamType = 'audio' | 'video'
+
+function getFFmpeg (input: string, type: 'live' | 'vod') {
+  // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
+  const command = ffmpeg(input, {
+    niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
+    cwd: CONFIG.STORAGE.TMP_DIR
+  })
+
+  const threads = type === 'live'
+    ? CONFIG.LIVE.TRANSCODING.THREADS
+    : CONFIG.TRANSCODING.THREADS
+
+  if (threads > 0) {
+    // If we don't set any threads ffmpeg will chose automatically
+    command.outputOption('-threads ' + threads)
+  }
+
+  return command
+}
+
+function getFFmpegVersion () {
+  return new Promise<string>((res, rej) => {
+    (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
+      if (err) return rej(err)
+      if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
+
+      return execPromise(`${ffmpegPath} -version`)
+        .then(stdout => {
+          const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
+          if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
+
+          // Fix ffmpeg version that does not include patch version (4.4 for example)
+          let version = parsed[1]
+          if (version.match(/^\d+\.\d+$/)) {
+            version += '.0'
+          }
+
+          return res(version)
+        })
+        .catch(err => rej(err))
+    })
+  })
+}
+
+async function runCommand (options: {
+  command: FfmpegCommand
+  silent?: boolean // false by default
+  job?: Job
+}) {
+  const { command, silent = false, job } = options
+
+  return new Promise<void>((res, rej) => {
+    let shellCommand: string
+
+    command.on('start', cmdline => { shellCommand = cmdline })
+
+    command.on('error', (err, stdout, stderr) => {
+      if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
+
+      rej(err)
+    })
+
+    command.on('end', (stdout, stderr) => {
+      logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
+
+      res()
+    })
+
+    if (job) {
+      command.on('progress', progress => {
+        if (!progress.percent) return
+
+        job.progress(Math.round(progress.percent))
+          .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
+      })
+    }
+
+    command.run()
+  })
+}
+
+function buildStreamSuffix (base: string, streamNum?: number) {
+  if (streamNum !== undefined) {
+    return `${base}:${streamNum}`
+  }
+
+  return base
+}
+
+function getScaleFilter (options: EncoderOptions): string {
+  if (options.scaleFilter) return options.scaleFilter.name
+
+  return 'scale'
+}
+
+export {
+  getFFmpeg,
+  getFFmpegVersion,
+  runCommand,
+  StreamType,
+  buildStreamSuffix,
+  getScaleFilter
+}
diff --git a/server/helpers/ffmpeg/ffmpeg-edition.ts b/server/helpers/ffmpeg/ffmpeg-edition.ts
new file mode 100644 (file)
index 0000000..a5baa7e
--- /dev/null
@@ -0,0 +1,242 @@
+import { FilterSpecification } from 'fluent-ffmpeg'
+import { VIDEO_FILTERS } from '@server/initializers/constants'
+import { AvailableEncoders } from '@shared/models'
+import { logger, loggerTagsFactory } from '../logger'
+import { getFFmpeg, runCommand } from './ffmpeg-commons'
+import { presetCopy, presetVOD } from './ffmpeg-presets'
+import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
+
+const lTags = loggerTagsFactory('ffmpeg')
+
+async function cutVideo (options: {
+  inputPath: string
+  outputPath: string
+  start?: number
+  end?: number
+}) {
+  const { inputPath, outputPath } = options
+
+  logger.debug('Will cut the video.', { options, ...lTags() })
+
+  let command = getFFmpeg(inputPath, 'vod')
+    .output(outputPath)
+
+  command = presetCopy(command)
+
+  if (options.start) command.inputOption('-ss ' + options.start)
+
+  if (options.end) {
+    const endSeeking = options.end - (options.start || 0)
+
+    command.outputOption('-to ' + endSeeking)
+  }
+
+  await runCommand({ command })
+}
+
+async function addWatermark (options: {
+  inputPath: string
+  watermarkPath: string
+  outputPath: string
+
+  availableEncoders: AvailableEncoders
+  profile: string
+}) {
+  const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options
+
+  logger.debug('Will add watermark to the video.', { options, ...lTags() })
+
+  const videoProbe = await ffprobePromise(inputPath)
+  const fps = await getVideoStreamFPS(inputPath, videoProbe)
+  const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
+
+  let command = getFFmpeg(inputPath, 'vod')
+    .output(outputPath)
+  command.input(watermarkPath)
+
+  command = await presetVOD({
+    command,
+    input: inputPath,
+    availableEncoders,
+    profile,
+    resolution,
+    fps,
+    canCopyAudio: true,
+    canCopyVideo: false
+  })
+
+  const complexFilter: FilterSpecification[] = [
+    // Scale watermark
+    {
+      inputs: [ '[1]', '[0]' ],
+      filter: 'scale2ref',
+      options: {
+        w: 'oh*mdar',
+        h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}`
+      },
+      outputs: [ '[watermark]', '[video]' ]
+    },
+
+    {
+      inputs: [ '[video]', '[watermark]' ],
+      filter: 'overlay',
+      options: {
+        x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`,
+        y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}`
+      }
+    }
+  ]
+
+  command.complexFilter(complexFilter)
+
+  await runCommand({ command })
+}
+
+async function addIntroOutro (options: {
+  inputPath: string
+  introOutroPath: string
+  outputPath: string
+  type: 'intro' | 'outro'
+
+  availableEncoders: AvailableEncoders
+  profile: string
+}) {
+  const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options
+
+  logger.debug('Will add intro/outro to the video.', { options, ...lTags() })
+
+  const mainProbe = await ffprobePromise(inputPath)
+  const fps = await getVideoStreamFPS(inputPath, mainProbe)
+  const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
+  const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
+
+  const introOutroProbe = await ffprobePromise(introOutroPath)
+  const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
+
+  let command = getFFmpeg(inputPath, 'vod')
+    .output(outputPath)
+
+  command.input(introOutroPath)
+
+  if (!introOutroHasAudio && mainHasAudio) {
+    const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
+
+    command.input('anullsrc')
+    command.withInputFormat('lavfi')
+    command.withInputOption('-t ' + duration)
+  }
+
+  command = await presetVOD({
+    command,
+    input: inputPath,
+    availableEncoders,
+    profile,
+    resolution,
+    fps,
+    canCopyAudio: false,
+    canCopyVideo: false
+  })
+
+  // Add black background to correctly scale intro/outro with padding
+  const complexFilter: FilterSpecification[] = [
+    {
+      inputs: [ '1', '0' ],
+      filter: 'scale2ref',
+      options: {
+        w: 'iw',
+        h: `ih`
+      },
+      outputs: [ 'intro-outro', 'main' ]
+    },
+    {
+      inputs: [ 'intro-outro', 'main' ],
+      filter: 'scale2ref',
+      options: {
+        w: 'iw',
+        h: `ih`
+      },
+      outputs: [ 'to-scale', 'main' ]
+    },
+    {
+      inputs: 'to-scale',
+      filter: 'drawbox',
+      options: {
+        t: 'fill'
+      },
+      outputs: [ 'to-scale-bg' ]
+    },
+    {
+      inputs: [ '1', 'to-scale-bg' ],
+      filter: 'scale2ref',
+      options: {
+        w: 'iw',
+        h: 'ih',
+        force_original_aspect_ratio: 'decrease',
+        flags: 'spline'
+      },
+      outputs: [ 'to-scale', 'to-scale-bg' ]
+    },
+    {
+      inputs: [ 'to-scale-bg', 'to-scale' ],
+      filter: 'overlay',
+      options: {
+        x: '(main_w - overlay_w)/2',
+        y: '(main_h - overlay_h)/2'
+      },
+      outputs: 'intro-outro-resized'
+    }
+  ]
+
+  const concatFilter = {
+    inputs: [],
+    filter: 'concat',
+    options: {
+      n: 2,
+      v: 1,
+      unsafe: 1
+    },
+    outputs: [ 'v' ]
+  }
+
+  const introOutroFilterInputs = [ 'intro-outro-resized' ]
+  const mainFilterInputs = [ 'main' ]
+
+  if (mainHasAudio) {
+    mainFilterInputs.push('0:a')
+
+    if (introOutroHasAudio) {
+      introOutroFilterInputs.push('1:a')
+    } else {
+      // Silent input
+      introOutroFilterInputs.push('2:a')
+    }
+  }
+
+  if (type === 'intro') {
+    concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
+  } else {
+    concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
+  }
+
+  if (mainHasAudio) {
+    concatFilter.options['a'] = 1
+    concatFilter.outputs.push('a')
+
+    command.outputOption('-map [a]')
+  }
+
+  command.outputOption('-map [v]')
+
+  complexFilter.push(concatFilter)
+  command.complexFilter(complexFilter)
+
+  await runCommand({ command })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  cutVideo,
+  addIntroOutro,
+  addWatermark
+}
diff --git a/server/helpers/ffmpeg/ffmpeg-encoders.ts b/server/helpers/ffmpeg/ffmpeg-encoders.ts
new file mode 100644 (file)
index 0000000..5bd80ba
--- /dev/null
@@ -0,0 +1,116 @@
+import { getAvailableEncoders } from 'fluent-ffmpeg'
+import { pick } from '@shared/core-utils'
+import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
+import { promisify0 } from '../core-utils'
+import { logger, loggerTagsFactory } from '../logger'
+
+const lTags = loggerTagsFactory('ffmpeg')
+
+// Detect supported encoders by ffmpeg
+let supportedEncoders: Map<string, boolean>
+async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
+  if (supportedEncoders !== undefined) {
+    return supportedEncoders
+  }
+
+  const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
+  const availableFFmpegEncoders = await getAvailableEncodersPromise()
+
+  const searchEncoders = new Set<string>()
+  for (const type of [ 'live', 'vod' ]) {
+    for (const streamType of [ 'audio', 'video' ]) {
+      for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
+        searchEncoders.add(encoder)
+      }
+    }
+  }
+
+  supportedEncoders = new Map<string, boolean>()
+
+  for (const searchEncoder of searchEncoders) {
+    supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
+  }
+
+  logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
+
+  return supportedEncoders
+}
+
+function resetSupportedEncoders () {
+  supportedEncoders = undefined
+}
+
+// Run encoder builder depending on available encoders
+// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
+// If the default one does not exist, check the next encoder
+async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
+  streamType: 'video' | 'audio'
+  input: string
+
+  availableEncoders: AvailableEncoders
+  profile: string
+
+  videoType: 'vod' | 'live'
+}) {
+  const { availableEncoders, profile, streamType, videoType } = options
+
+  const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
+  const encoders = availableEncoders.available[videoType]
+
+  for (const encoder of encodersToTry) {
+    if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
+      logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
+      continue
+    }
+
+    if (!encoders[encoder]) {
+      logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
+      continue
+    }
+
+    // An object containing available profiles for this encoder
+    const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
+    let builder = builderProfiles[profile]
+
+    if (!builder) {
+      logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
+      builder = builderProfiles.default
+
+      if (!builder) {
+        logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
+        continue
+      }
+    }
+
+    const result = await builder(
+      pick(options, [
+        'input',
+        'canCopyAudio',
+        'canCopyVideo',
+        'resolution',
+        'inputBitrate',
+        'fps',
+        'inputRatio',
+        'streamNum'
+      ])
+    )
+
+    return {
+      result,
+
+      // If we don't have output options, then copy the input stream
+      encoder: result.copy === true
+        ? 'copy'
+        : encoder
+    }
+  }
+
+  return null
+}
+
+export {
+  checkFFmpegEncoders,
+  resetSupportedEncoders,
+
+  getEncoderBuilderResult
+}
diff --git a/server/helpers/ffmpeg/ffmpeg-images.ts b/server/helpers/ffmpeg/ffmpeg-images.ts
new file mode 100644 (file)
index 0000000..7f64c6d
--- /dev/null
@@ -0,0 +1,46 @@
+import ffmpeg from 'fluent-ffmpeg'
+import { FFMPEG_NICE } from '@server/initializers/constants'
+import { runCommand } from './ffmpeg-commons'
+
+function convertWebPToJPG (path: string, destination: string): Promise<void> {
+  const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
+    .output(destination)
+
+  return runCommand({ command, silent: true })
+}
+
+function processGIF (
+  path: string,
+  destination: string,
+  newSize: { width: number, height: number }
+): Promise<void> {
+  const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
+    .fps(20)
+    .size(`${newSize.width}x${newSize.height}`)
+    .output(destination)
+
+  return runCommand({ command })
+}
+
+async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) {
+  const pendingImageName = 'pending-' + imageName
+
+  const options = {
+    filename: pendingImageName,
+    count: 1,
+    folder
+  }
+
+  return new Promise<string>((res, rej) => {
+    ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
+      .on('error', rej)
+      .on('end', () => res(imageName))
+      .thumbnail(options)
+  })
+}
+
+export {
+  convertWebPToJPG,
+  processGIF,
+  generateThumbnailFromVideo
+}
diff --git a/server/helpers/ffmpeg/ffmpeg-live.ts b/server/helpers/ffmpeg/ffmpeg-live.ts
new file mode 100644 (file)
index 0000000..ff57162
--- /dev/null
@@ -0,0 +1,161 @@
+import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
+import { join } from 'path'
+import { VIDEO_LIVE } from '@server/initializers/constants'
+import { AvailableEncoders } from '@shared/models'
+import { logger, loggerTagsFactory } from '../logger'
+import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
+import { getEncoderBuilderResult } from './ffmpeg-encoders'
+import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
+import { computeFPS } from './ffprobe-utils'
+
+const lTags = loggerTagsFactory('ffmpeg')
+
+async function getLiveTranscodingCommand (options: {
+  inputUrl: string
+
+  outPath: string
+  masterPlaylistName: string
+
+  resolutions: number[]
+
+  // Input information
+  fps: number
+  bitrate: number
+  ratio: number
+
+  availableEncoders: AvailableEncoders
+  profile: string
+}) {
+  const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
+
+  const command = getFFmpeg(inputUrl, 'live')
+
+  const varStreamMap: string[] = []
+
+  const complexFilter: FilterSpecification[] = [
+    {
+      inputs: '[v:0]',
+      filter: 'split',
+      options: resolutions.length,
+      outputs: resolutions.map(r => `vtemp${r}`)
+    }
+  ]
+
+  command.outputOption('-sc_threshold 0')
+
+  addDefaultEncoderGlobalParams(command)
+
+  for (let i = 0; i < resolutions.length; i++) {
+    const resolution = resolutions[i]
+    const resolutionFPS = computeFPS(fps, resolution)
+
+    const baseEncoderBuilderParams = {
+      input: inputUrl,
+
+      availableEncoders,
+      profile,
+
+      canCopyAudio: true,
+      canCopyVideo: true,
+
+      inputBitrate: bitrate,
+      inputRatio: ratio,
+
+      resolution,
+      fps: resolutionFPS,
+
+      streamNum: i,
+      videoType: 'live' as 'live'
+    }
+
+    {
+      const streamType: StreamType = 'video'
+      const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
+      if (!builderResult) {
+        throw new Error('No available live video encoder found')
+      }
+
+      command.outputOption(`-map [vout${resolution}]`)
+
+      addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
+
+      logger.debug(
+        'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
+        { builderResult, fps: resolutionFPS, resolution, ...lTags() }
+      )
+
+      command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
+      applyEncoderOptions(command, builderResult.result)
+
+      complexFilter.push({
+        inputs: `vtemp${resolution}`,
+        filter: getScaleFilter(builderResult.result),
+        options: `w=-2:h=${resolution}`,
+        outputs: `vout${resolution}`
+      })
+    }
+
+    {
+      const streamType: StreamType = 'audio'
+      const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
+      if (!builderResult) {
+        throw new Error('No available live audio encoder found')
+      }
+
+      command.outputOption('-map a:0')
+
+      addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
+
+      logger.debug(
+        'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
+        { builderResult, fps: resolutionFPS, resolution, ...lTags() }
+      )
+
+      command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
+      applyEncoderOptions(command, builderResult.result)
+    }
+
+    varStreamMap.push(`v:${i},a:${i}`)
+  }
+
+  command.complexFilter(complexFilter)
+
+  addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
+
+  command.outputOption('-var_stream_map', varStreamMap.join(' '))
+
+  return command
+}
+
+function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
+  const command = getFFmpeg(inputUrl, 'live')
+
+  command.outputOption('-c:v copy')
+  command.outputOption('-c:a copy')
+  command.outputOption('-map 0:a?')
+  command.outputOption('-map 0:v?')
+
+  addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
+
+  return command
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  getLiveTranscodingCommand,
+  getLiveMuxingCommand
+}
+
+// ---------------------------------------------------------------------------
+
+function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
+  command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
+  command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
+  command.outputOption('-hls_flags delete_segments+independent_segments')
+  command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
+  command.outputOption('-master_pl_name ' + masterPlaylistName)
+  command.outputOption(`-f hls`)
+
+  command.output(join(outPath, '%v.m3u8'))
+}
diff --git a/server/helpers/ffmpeg/ffmpeg-presets.ts b/server/helpers/ffmpeg/ffmpeg-presets.ts
new file mode 100644 (file)
index 0000000..99b39f7
--- /dev/null
@@ -0,0 +1,156 @@
+import { FfmpegCommand } from 'fluent-ffmpeg'
+import { pick } from 'lodash'
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { AvailableEncoders, EncoderOptions } from '@shared/models'
+import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
+import { getEncoderBuilderResult } from './ffmpeg-encoders'
+import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
+
+const lTags = loggerTagsFactory('ffmpeg')
+
+// ---------------------------------------------------------------------------
+
+function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
+  // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
+  command.outputOption('-max_muxing_queue_size 1024')
+         // strip all metadata
+         .outputOption('-map_metadata -1')
+         // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
+         .outputOption('-pix_fmt yuv420p')
+}
+
+function addDefaultEncoderParams (options: {
+  command: FfmpegCommand
+  encoder: 'libx264' | string
+  fps: number
+
+  streamNum?: number
+}) {
+  const { command, encoder, fps, streamNum } = options
+
+  if (encoder === 'libx264') {
+    // 3.1 is the minimal resource allocation for our highest supported resolution
+    command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
+
+    if (fps) {
+      // Keyframe interval of 2 seconds for faster seeking and resolution switching.
+      // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
+      // https://superuser.com/a/908325
+      command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+async function presetVOD (options: {
+  command: FfmpegCommand
+  input: string
+
+  availableEncoders: AvailableEncoders
+  profile: string
+
+  canCopyAudio: boolean
+  canCopyVideo: boolean
+
+  resolution: number
+  fps: number
+
+  scaleFilterValue?: string
+}) {
+  const { command, input, profile, resolution, fps, scaleFilterValue } = options
+
+  let localCommand = command
+    .format('mp4')
+    .outputOption('-movflags faststart')
+
+  addDefaultEncoderGlobalParams(command)
+
+  const probe = await ffprobePromise(input)
+
+  // Audio encoder
+  const bitrate = await getVideoStreamBitrate(input, probe)
+  const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
+
+  let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
+
+  if (!await hasAudioStream(input, probe)) {
+    localCommand = localCommand.noAudio()
+    streamsToProcess = [ 'video' ]
+  }
+
+  for (const streamType of streamsToProcess) {
+    const builderResult = await getEncoderBuilderResult({
+      ...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]),
+
+      input,
+      inputBitrate: bitrate,
+      inputRatio: videoStreamDimensions?.ratio || 0,
+
+      profile,
+      resolution,
+      fps,
+      streamType,
+
+      videoType: 'vod' as 'vod'
+    })
+
+    if (!builderResult) {
+      throw new Error('No available encoder found for stream ' + streamType)
+    }
+
+    logger.debug(
+      'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
+      builderResult.encoder, streamType, input, profile,
+      { builderResult, resolution, fps, ...lTags() }
+    )
+
+    if (streamType === 'video') {
+      localCommand.videoCodec(builderResult.encoder)
+
+      if (scaleFilterValue) {
+        localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
+      }
+    } else if (streamType === 'audio') {
+      localCommand.audioCodec(builderResult.encoder)
+    }
+
+    applyEncoderOptions(localCommand, builderResult.result)
+    addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
+  }
+
+  return localCommand
+}
+
+function presetCopy (command: FfmpegCommand): FfmpegCommand {
+  return command
+    .format('mp4')
+    .videoCodec('copy')
+    .audioCodec('copy')
+}
+
+function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
+  return command
+    .format('mp4')
+    .audioCodec('copy')
+    .noVideo()
+}
+
+function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
+  return command
+    .inputOptions(options.inputOptions ?? [])
+    .outputOptions(options.outputOptions ?? [])
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  presetVOD,
+  presetCopy,
+  presetOnlyAudio,
+
+  addDefaultEncoderGlobalParams,
+  addDefaultEncoderParams,
+
+  applyEncoderOptions
+}
diff --git a/server/helpers/ffmpeg/ffmpeg-vod.ts b/server/helpers/ffmpeg/ffmpeg-vod.ts
new file mode 100644 (file)
index 0000000..c3622ce
--- /dev/null
@@ -0,0 +1,254 @@
+import { Job } from 'bull'
+import { FfmpegCommand } from 'fluent-ffmpeg'
+import { readFile, writeFile } from 'fs-extra'
+import { dirname } from 'path'
+import { pick } from '@shared/core-utils'
+import { AvailableEncoders, VideoResolution } from '@shared/models'
+import { logger, loggerTagsFactory } from '../logger'
+import { getFFmpeg, runCommand } from './ffmpeg-commons'
+import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
+import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
+import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
+
+const lTags = loggerTagsFactory('ffmpeg')
+
+// ---------------------------------------------------------------------------
+
+type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
+
+interface BaseTranscodeVODOptions {
+  type: TranscodeVODOptionsType
+
+  inputPath: string
+  outputPath: string
+
+  availableEncoders: AvailableEncoders
+  profile: string
+
+  resolution: number
+
+  isPortraitMode?: boolean
+
+  job?: Job
+}
+
+interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
+  type: 'hls'
+  copyCodecs: boolean
+  hlsPlaylist: {
+    videoFilename: string
+  }
+}
+
+interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
+  type: 'hls-from-ts'
+
+  isAAC: boolean
+
+  hlsPlaylist: {
+    videoFilename: string
+  }
+}
+
+interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
+  type: 'quick-transcode'
+}
+
+interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
+  type: 'video'
+}
+
+interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
+  type: 'merge-audio'
+  audioPath: string
+}
+
+interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
+  type: 'only-audio'
+}
+
+type TranscodeVODOptions =
+  HLSTranscodeOptions
+  | HLSFromTSTranscodeOptions
+  | VideoTranscodeOptions
+  | MergeAudioTranscodeOptions
+  | OnlyAudioTranscodeOptions
+  | QuickTranscodeOptions
+
+// ---------------------------------------------------------------------------
+
+const builders: {
+  [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
+} = {
+  'quick-transcode': buildQuickTranscodeCommand,
+  'hls': buildHLSVODCommand,
+  'hls-from-ts': buildHLSVODFromTSCommand,
+  'merge-audio': buildAudioMergeCommand,
+  'only-audio': buildOnlyAudioCommand,
+  'video': buildVODCommand
+}
+
+async function transcodeVOD (options: TranscodeVODOptions) {
+  logger.debug('Will run transcode.', { options, ...lTags() })
+
+  let command = getFFmpeg(options.inputPath, 'vod')
+    .output(options.outputPath)
+
+  command = await builders[options.type](command, options)
+
+  await runCommand({ command, job: options.job })
+
+  await fixHLSPlaylistIfNeeded(options)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  transcodeVOD,
+
+  buildVODCommand,
+
+  TranscodeVODOptions,
+  TranscodeVODOptionsType
+}
+
+// ---------------------------------------------------------------------------
+
+async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
+  let fps = await getVideoStreamFPS(options.inputPath)
+  fps = computeFPS(fps, options.resolution)
+
+  let scaleFilterValue: string
+
+  if (options.resolution !== undefined) {
+    scaleFilterValue = options.isPortraitMode === true
+      ? `w=${options.resolution}:h=-2`
+      : `w=-2:h=${options.resolution}`
+  }
+
+  command = await presetVOD({
+    ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
+
+    command,
+    input: options.inputPath,
+    canCopyAudio: true,
+    canCopyVideo: true,
+    fps,
+    scaleFilterValue
+  })
+
+  return command
+}
+
+function buildQuickTranscodeCommand (command: FfmpegCommand) {
+  command = presetCopy(command)
+
+  command = command.outputOption('-map_metadata -1') // strip all metadata
+                   .outputOption('-movflags faststart')
+
+  return command
+}
+
+// ---------------------------------------------------------------------------
+// Audio transcoding
+// ---------------------------------------------------------------------------
+
+async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
+  command = command.loop(undefined)
+
+  const scaleFilterValue = getMergeAudioScaleFilterValue()
+  command = await presetVOD({
+    ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
+
+    command,
+    input: options.audioPath,
+    canCopyAudio: true,
+    canCopyVideo: true,
+    fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
+    scaleFilterValue
+  })
+
+  command.outputOption('-preset:v veryfast')
+
+  command = command.input(options.audioPath)
+                   .outputOption('-tune stillimage')
+                   .outputOption('-shortest')
+
+  return command
+}
+
+function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
+  command = presetOnlyAudio(command)
+
+  return command
+}
+
+// ---------------------------------------------------------------------------
+// HLS transcoding
+// ---------------------------------------------------------------------------
+
+async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
+  const videoPath = getHLSVideoPath(options)
+
+  if (options.copyCodecs) command = presetCopy(command)
+  else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
+  else command = await buildVODCommand(command, options)
+
+  addCommonHLSVODCommandOptions(command, videoPath)
+
+  return command
+}
+
+function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
+  const videoPath = getHLSVideoPath(options)
+
+  command.outputOption('-c copy')
+
+  if (options.isAAC) {
+    // Required for example when copying an AAC stream from an MPEG-TS
+    // Since it's a bitstream filter, we don't need to reencode the audio
+    command.outputOption('-bsf:a aac_adtstoasc')
+  }
+
+  addCommonHLSVODCommandOptions(command, videoPath)
+
+  return command
+}
+
+function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
+  return command.outputOption('-hls_time 4')
+                .outputOption('-hls_list_size 0')
+                .outputOption('-hls_playlist_type vod')
+                .outputOption('-hls_segment_filename ' + outputPath)
+                .outputOption('-hls_segment_type fmp4')
+                .outputOption('-f hls')
+                .outputOption('-hls_flags single_file')
+}
+
+async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
+  if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
+
+  const fileContent = await readFile(options.outputPath)
+
+  const videoFileName = options.hlsPlaylist.videoFilename
+  const videoFilePath = getHLSVideoPath(options)
+
+  // Fix wrong mapping with some ffmpeg versions
+  const newContent = fileContent.toString()
+                                .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
+
+  await writeFile(options.outputPath, newContent)
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
+  return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
+}
+
+// Avoid "height not divisible by 2" error
+function getMergeAudioScaleFilterValue () {
+  return 'trunc(iw/2)*2:trunc(ih/2)*2'
+}
similarity index 67%
rename from server/helpers/ffprobe-utils.ts
rename to server/helpers/ffmpeg/ffprobe-utils.ts
index 595112bce602b321a9a1c19ced49c5d81ad9d183..07bcf01f45a0f08c6644ec8d04e2613a2a5752e4 100644 (file)
@@ -1,22 +1,21 @@
 import { FfprobeData } from 'fluent-ffmpeg'
 import { getMaxBitrate } from '@shared/core-utils'
-import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos'
-import { CONFIG } from '../initializers/config'
-import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
-import { logger } from './logger'
 import {
-  canDoQuickAudioTranscode,
   ffprobePromise,
-  getDurationFromVideoFile,
   getAudioStream,
+  getVideoStreamDuration,
   getMaxAudioBitrate,
-  getMetadataFromFile,
-  getVideoFileBitrate,
-  getVideoFileFPS,
-  getVideoFileResolution,
-  getVideoStreamFromFile,
-  getVideoStreamSize
+  buildFileMetadata,
+  getVideoStreamBitrate,
+  getVideoStreamFPS,
+  getVideoStream,
+  getVideoStreamDimensionsInfo,
+  hasAudioStream
 } from '@shared/extra-utils/ffprobe'
+import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
+import { CONFIG } from '../../initializers/config'
+import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
+import { logger } from '../logger'
 
 /**
  *
@@ -24,9 +23,12 @@ import {
  *
  */
 
-async function getVideoStreamCodec (path: string) {
-  const videoStream = await getVideoStreamFromFile(path)
+// ---------------------------------------------------------------------------
+// Codecs
+// ---------------------------------------------------------------------------
 
+async function getVideoStreamCodec (path: string) {
+  const videoStream = await getVideoStream(path)
   if (!videoStream) return ''
 
   const videoCodec = videoStream.codec_tag_string
@@ -83,6 +85,10 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
   return 'mp4a.40.2' // Fallback
 }
 
+// ---------------------------------------------------------------------------
+// Resolutions
+// ---------------------------------------------------------------------------
+
 function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
   const configResolutions = type === 'vod'
     ? CONFIG.TRANSCODING.RESOLUTIONS
@@ -112,6 +118,10 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
   return resolutionsEnabled
 }
 
+// ---------------------------------------------------------------------------
+// Can quick transcode
+// ---------------------------------------------------------------------------
+
 async function canDoQuickTranscode (path: string): Promise<boolean> {
   if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
 
@@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
          await canDoQuickAudioTranscode(path, probe)
 }
 
+async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
+  const parsedAudio = await getAudioStream(path, probe)
+
+  if (!parsedAudio.audioStream) return true
+
+  if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
+
+  const audioBitrate = parsedAudio.bitrate
+  if (!audioBitrate) return false
+
+  const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
+  if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
+
+  const channelLayout = parsedAudio.audioStream['channel_layout']
+  // Causes playback issues with Chrome
+  if (!channelLayout || channelLayout === 'unknown') return false
+
+  return true
+}
+
 async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
-  const videoStream = await getVideoStreamFromFile(path, probe)
-  const fps = await getVideoFileFPS(path, probe)
-  const bitRate = await getVideoFileBitrate(path, probe)
-  const resolutionData = await getVideoFileResolution(path, probe)
+  const videoStream = await getVideoStream(path, probe)
+  const fps = await getVideoStreamFPS(path, probe)
+  const bitRate = await getVideoStreamBitrate(path, probe)
+  const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
 
   // If ffprobe did not manage to guess the bitrate
   if (!bitRate) return false
 
   // check video params
-  if (videoStream == null) return false
+  if (!videoStream) return false
   if (videoStream['codec_name'] !== 'h264') return false
   if (videoStream['pix_fmt'] !== 'yuv420p') return false
   if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
@@ -140,6 +170,10 @@ async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Pro
   return true
 }
 
+// ---------------------------------------------------------------------------
+// Framerate
+// ---------------------------------------------------------------------------
+
 function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
   return VIDEO_TRANSCODING_FPS[type].slice(0)
                                     .sort((a, b) => fps % a - fps % b)[0]
@@ -171,21 +205,26 @@ function computeFPS (fpsArg: number, resolution: VideoResolution) {
 // ---------------------------------------------------------------------------
 
 export {
-  getVideoStreamCodec,
-  getAudioStreamCodec,
-  getVideoStreamSize,
-  getVideoFileResolution,
-  getMetadataFromFile,
+  // Re export ffprobe utils
+  getVideoStreamDimensionsInfo,
+  buildFileMetadata,
   getMaxAudioBitrate,
-  getVideoStreamFromFile,
-  getDurationFromVideoFile,
+  getVideoStream,
+  getVideoStreamDuration,
   getAudioStream,
-  computeFPS,
-  getVideoFileFPS,
+  hasAudioStream,
+  getVideoStreamFPS,
   ffprobePromise,
+  getVideoStreamBitrate,
+
+  getVideoStreamCodec,
+  getAudioStreamCodec,
+
+  computeFPS,
   getClosestFramerateStandard,
+
   computeLowerResolutionsToTranscode,
-  getVideoFileBitrate,
+
   canDoQuickTranscode,
   canDoQuickVideoTranscode,
   canDoQuickAudioTranscode
diff --git a/server/helpers/ffmpeg/index.ts b/server/helpers/ffmpeg/index.ts
new file mode 100644 (file)
index 0000000..e3bb201
--- /dev/null
@@ -0,0 +1,8 @@
+export * from './ffmpeg-commons'
+export * from './ffmpeg-edition'
+export * from './ffmpeg-encoders'
+export * from './ffmpeg-images'
+export * from './ffmpeg-live'
+export * from './ffmpeg-presets'
+export * from './ffmpeg-vod'
+export * from './ffprobe-utils'
index b174ae4369208e8d10eeec1816425c6d4ed7fb63..6e4a2b0003c0dc85e06e335f58ab6f29c09856af 100644 (file)
@@ -1,9 +1,12 @@
 import { copy, readFile, remove, rename } from 'fs-extra'
 import Jimp, { read } from 'jimp'
+import { join } from 'path'
 import { getLowercaseExtension } from '@shared/core-utils'
 import { buildUUID } from '@shared/extra-utils'
-import { convertWebPToJPG, processGIF } from './ffmpeg-utils'
-import { logger } from './logger'
+import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
+import { logger, loggerTagsFactory } from './logger'
+
+const lTags = loggerTagsFactory('image-utils')
 
 function generateImageFilename (extension = '.jpg') {
   return buildUUID() + extension
@@ -33,10 +36,31 @@ async function processImage (
   if (keepOriginal !== true) await remove(path)
 }
 
+async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
+  const pendingImageName = 'pending-' + imageName
+  const pendingImagePath = join(folder, pendingImageName)
+
+  try {
+    await generateThumbnailFromVideo(fromPath, folder, imageName)
+
+    const destination = join(folder, imageName)
+    await processImage(pendingImagePath, destination, size)
+  } catch (err) {
+    logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
+
+    try {
+      await remove(pendingImagePath)
+    } catch (err) {
+      logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
+    }
+  }
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   generateImageFilename,
+  generateImageFromVideoFile,
   processImage
 }
 
index 68d532c48995a2f5efde250c233aec100f800594..88bdb16b630c7688d8d62440b1d9c9be0ba1ea8a 100644 (file)
@@ -91,6 +91,16 @@ async function downloadWebTorrentVideo (target: { uri: string, torrentName?: str
 }
 
 function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
+  return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => {
+    return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath)
+  })
+}
+
+async function createTorrentAndSetInfoHashFromPath (
+  videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
+  videoFile: MVideoFile,
+  filePath: string
+) {
   const video = extractVideo(videoOrPlaylist)
 
   const options = {
@@ -101,24 +111,22 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
     urlList: buildUrlList(video, videoFile)
   }
 
-  return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), async videoPath => {
-    const torrentContent = await createTorrentPromise(videoPath, options)
+  const torrentContent = await createTorrentPromise(filePath, options)
 
-    const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
-    const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
-    logger.info('Creating torrent %s.', torrentPath)
+  const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
+  const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
+  logger.info('Creating torrent %s.', torrentPath)
 
-    await writeFile(torrentPath, torrentContent)
+  await writeFile(torrentPath, torrentContent)
 
-    // Remove old torrent file if it existed
-    if (videoFile.hasTorrent()) {
-      await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
-    }
+  // Remove old torrent file if it existed
+  if (videoFile.hasTorrent()) {
+    await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
+  }
 
-    const parsedTorrent = parseTorrent(torrentContent)
-    videoFile.infoHash = parsedTorrent.infoHash
-    videoFile.torrentFilename = torrentFilename
-  })
+  const parsedTorrent = parseTorrent(torrentContent)
+  videoFile.infoHash = parsedTorrent.infoHash
+  videoFile.torrentFilename = torrentFilename
 }
 
 async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
@@ -177,7 +185,10 @@ function generateMagnetUri (
 export {
   createTorrentPromise,
   updateTorrentMetadata,
+
   createTorrentAndSetInfoHash,
+  createTorrentAndSetInfoHashFromPath,
+
   generateMagnetUri,
   downloadWebTorrentVideo
 }
index 57ef0d218afbaacbb934d2fdc971aa14707f0db4..635a32010b66dab45b1130daabff4e5ff0ada095 100644 (file)
@@ -1,7 +1,7 @@
 import config from 'config'
 import { uniq } from 'lodash'
 import { URL } from 'url'
-import { getFFmpegVersion } from '@server/helpers/ffmpeg-utils'
+import { getFFmpegVersion } from '@server/helpers/ffmpeg'
 import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
 import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
 import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
@@ -31,8 +31,7 @@ async function checkActivityPubUrls () {
   }
 }
 
-// Some checks on configuration files
-// Return an error message, or null if everything is okay
+// Some checks on configuration files or throw if there is an error
 function checkConfig () {
 
   // Moved configuration keys
@@ -40,61 +39,124 @@ function checkConfig () {
     logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
   }
 
-  // Email verification
+  checkEmailConfig()
+  checkNSFWPolicyConfig()
+  checkLocalRedundancyConfig()
+  checkRemoteRedundancyConfig()
+  checkStorageConfig()
+  checkTranscodingConfig()
+  checkBroadcastMessageConfig()
+  checkSearchConfig()
+  checkLiveConfig()
+  checkObjectStorageConfig()
+  checkVideoEditorConfig()
+}
+
+// We get db by param to not import it in this file (import orders)
+async function clientsExist () {
+  const totalClients = await OAuthClientModel.countTotal()
+
+  return totalClients !== 0
+}
+
+// We get db by param to not import it in this file (import orders)
+async function usersExist () {
+  const totalUsers = await UserModel.countTotal()
+
+  return totalUsers !== 0
+}
+
+// We get db by param to not import it in this file (import orders)
+async function applicationExist () {
+  const totalApplication = await ApplicationModel.countTotal()
+
+  return totalApplication !== 0
+}
+
+async function checkFFmpegVersion () {
+  const version = await getFFmpegVersion()
+  const { major, minor } = parseSemVersion(version)
+
+  if (major < 4 || (major === 4 && minor < 1)) {
+    logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  checkConfig,
+  clientsExist,
+  checkFFmpegVersion,
+  usersExist,
+  applicationExist,
+  checkActivityPubUrls
+}
+
+// ---------------------------------------------------------------------------
+
+function checkEmailConfig () {
   if (!isEmailEnabled()) {
     if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
-      return 'Emailer is disabled but you require signup email verification.'
+      throw new Error('Emailer is disabled but you require signup email verification.')
     }
 
     if (CONFIG.CONTACT_FORM.ENABLED) {
       logger.warn('Emailer is disabled so the contact form will not work.')
     }
   }
+}
 
-  // NSFW policy
+function checkNSFWPolicyConfig () {
   const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
-  {
-    const available = [ 'do_not_list', 'blur', 'display' ]
-    if (available.includes(defaultNSFWPolicy) === false) {
-      return 'NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy
-    }
+
+  const available = [ 'do_not_list', 'blur', 'display' ]
+  if (available.includes(defaultNSFWPolicy) === false) {
+    throw new Error('NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy)
   }
+}
 
-  // Redundancies
+function checkLocalRedundancyConfig () {
   const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
+
   if (isArray(redundancyVideos)) {
     const available = [ 'most-views', 'trending', 'recently-added' ]
+
     for (const r of redundancyVideos) {
       if (available.includes(r.strategy) === false) {
-        return 'Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy
+        throw new Error('Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy)
       }
 
       // Lifetime should not be < 10 hours
       if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) {
-        return 'Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy
+        throw new Error('Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy)
       }
     }
 
     const filtered = uniq(redundancyVideos.map(r => r.strategy))
     if (filtered.length !== redundancyVideos.length) {
-      return 'Redundancy video entries should have unique strategies'
+      throw new Error('Redundancy video entries should have unique strategies')
     }
 
     const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
     if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
-      return 'Min views in recently added strategy is not a number'
+      throw new Error('Min views in recently added strategy is not a number')
     }
   } else {
-    return 'Videos redundancy should be an array (you must uncomment lines containing - too)'
+    throw new Error('Videos redundancy should be an array (you must uncomment lines containing - too)')
   }
+}
 
-  // Remote redundancies
+function checkRemoteRedundancyConfig () {
   const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM
   const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ])
+
   if (acceptFromValues.has(acceptFrom) === false) {
-    return 'remote_redundancy.videos.accept_from has an incorrect value'
+    throw new Error('remote_redundancy.videos.accept_from has an incorrect value')
   }
+}
 
+function checkStorageConfig () {
   // Check storage directory locations
   if (isProdInstance()) {
     const configStorage = config.get('storage')
@@ -111,71 +173,76 @@ function checkConfig () {
   if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
     logger.warn('Redundancy directory should be different than the videos folder.')
   }
+}
 
-  // Transcoding
+function checkTranscodingConfig () {
   if (CONFIG.TRANSCODING.ENABLED) {
     if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
-      return 'You need to enable at least WebTorrent transcoding or HLS transcoding.'
+      throw new Error('You need to enable at least WebTorrent transcoding or HLS transcoding.')
     }
 
     if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
-      return 'Transcoding concurrency should be > 0'
+      throw new Error('Transcoding concurrency should be > 0')
     }
   }
 
   if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) {
     if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) {
-      return 'Video import concurrency should be > 0'
+      throw new Error('Video import concurrency should be > 0')
     }
   }
+}
 
-  // Broadcast message
+function checkBroadcastMessageConfig () {
   if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
     const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
     const available = [ 'info', 'warning', 'error' ]
 
     if (available.includes(currentLevel) === false) {
-      return 'Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel
+      throw new Error('Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel)
     }
   }
+}
 
-  // Search index
+function checkSearchConfig () {
   if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
     if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
-      return 'You cannot enable search index without enabling remote URI search for users.'
+      throw new Error('You cannot enable search index without enabling remote URI search for users.')
     }
   }
+}
 
-  // Live
+function checkLiveConfig () {
   if (CONFIG.LIVE.ENABLED === true) {
     if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
-      return 'Live allow replay cannot be enabled if transcoding is not enabled.'
+      throw new Error('Live allow replay cannot be enabled if transcoding is not enabled.')
     }
 
     if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) {
-      return 'You must enable at least RTMP or RTMPS'
+      throw new Error('You must enable at least RTMP or RTMPS')
     }
 
     if (CONFIG.LIVE.RTMPS.ENABLED) {
       if (!CONFIG.LIVE.RTMPS.KEY_FILE) {
-        return 'You must specify a key file to enabled RTMPS'
+        throw new Error('You must specify a key file to enabled RTMPS')
       }
 
       if (!CONFIG.LIVE.RTMPS.CERT_FILE) {
-        return 'You must specify a cert file to enable RTMPS'
+        throw new Error('You must specify a cert file to enable RTMPS')
       }
     }
   }
+}
 
-  // Object storage
+function checkObjectStorageConfig () {
   if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
 
     if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) {
-      return 'videos_bucket should be set when object storage support is enabled.'
+      throw new Error('videos_bucket should be set when object storage support is enabled.')
     }
 
     if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) {
-      return 'streaming_playlists_bucket should be set when object storage support is enabled.'
+      throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.')
     }
 
     if (
@@ -183,53 +250,18 @@ function checkConfig () {
       CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
     ) {
       if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') {
-        return 'Object storage bucket prefixes should be set when the same bucket is used for both types of video.'
-      } else {
-        return 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
+        throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.')
       }
+
+      throw new Error(
+        'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
+      )
     }
   }
-
-  return null
-}
-
-// We get db by param to not import it in this file (import orders)
-async function clientsExist () {
-  const totalClients = await OAuthClientModel.countTotal()
-
-  return totalClients !== 0
-}
-
-// We get db by param to not import it in this file (import orders)
-async function usersExist () {
-  const totalUsers = await UserModel.countTotal()
-
-  return totalUsers !== 0
 }
 
-// We get db by param to not import it in this file (import orders)
-async function applicationExist () {
-  const totalApplication = await ApplicationModel.countTotal()
-
-  return totalApplication !== 0
-}
-
-async function checkFFmpegVersion () {
-  const version = await getFFmpegVersion()
-  const { major, minor } = parseSemVersion(version)
-
-  if (major < 4 || (major === 4 && minor < 1)) {
-    logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
+function checkVideoEditorConfig () {
+  if (CONFIG.VIDEO_EDITOR.ENABLED === true && CONFIG.TRANSCODING.ENABLED === false) {
+    throw new Error('Video editor cannot be enabled if transcoding is disabled')
   }
 }
-
-// ---------------------------------------------------------------------------
-
-export {
-  checkConfig,
-  clientsExist,
-  checkFFmpegVersion,
-  usersExist,
-  applicationExist,
-  checkActivityPubUrls
-}
index 458005b9842f9cf5c134fd0c0ca1e656ddebfcf6..d9d90d4b4cd2430182a3e07a315a1e040c08225d 100644 (file)
@@ -30,7 +30,7 @@ function checkMissedConfig () {
     'transcoding.profile', 'transcoding.concurrency',
     '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.resolutions.2160p', 'video_editor.enabled',
     'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
     'trending.videos.interval_days',
     'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
index fb6f7ae62dec5008cae94a8c61afbf902cd9b193..c1b82d12f7f7c21a6caf907d6c5f079bcb12ab8c 100644 (file)
@@ -324,6 +324,9 @@ const CONFIG = {
       }
     }
   },
+  VIDEO_EDITOR: {
+    get ENABLED () { return config.get<boolean>('video_editor.enabled') }
+  },
   IMPORT: {
     VIDEOS: {
       get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
index 9b972b87e137be7c8e6ebfd65350bed4708e95fa..4d2a6fc631a9c0793ff3df8fdbc03f99816de2de 100644 (file)
@@ -152,6 +152,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
   'activitypub-refresher': 1,
   'video-redundancy': 1,
   'video-live-ending': 1,
+  'video-edition': 1,
   'move-to-object-storage': 3
 }
 // Excluded keys are jobs that can be configured by admins
@@ -168,6 +169,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
   'activitypub-refresher': 1,
   'video-redundancy': 1,
   'video-live-ending': 10,
+  'video-edition': 1,
   'move-to-object-storage': 1
 }
 const JOB_TTL: { [id in JobType]: number } = {
@@ -178,6 +180,7 @@ const JOB_TTL: { [id in JobType]: number } = {
   'activitypub-cleaner': 1000 * 3600, // 1 hour
   'video-file-import': 1000 * 3600, // 1 hour
   'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
+  'video-edition': 1000 * 3600 * 10, // 10 hours
   'video-import': 1000 * 3600 * 2, // 2 hours
   'email': 60000 * 10, // 10 minutes
   'actor-keys': 60000 * 20, // 20 minutes
@@ -351,6 +354,10 @@ const CONSTRAINTS_FIELDS = {
   },
   COMMONS: {
     URL: { min: 5, max: 2000 } // Length
+  },
+  VIDEO_EDITOR: {
+    TASKS: { min: 1, max: 10 }, // Number of tasks
+    CUT_TIME: { min: 0 } // Value
   }
 }
 
@@ -365,6 +372,7 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
   MIN: 1,
   STANDARD: [ 24, 25, 30 ],
   HD_STANDARD: [ 50, 60 ],
+  AUDIO_MERGE: 25,
   AVERAGE: 30,
   MAX: 60,
   KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
@@ -434,7 +442,8 @@ const VIDEO_STATES: { [ id in VideoState ]: string } = {
   [VideoState.LIVE_ENDED]: 'Livestream ended',
   [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
   [VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
-  [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed'
+  [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed',
+  [VideoState.TO_EDIT]: 'To edit*'
 }
 
 const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
@@ -855,6 +864,16 @@ const FILES_CONTENT_HASH = {
 
 // ---------------------------------------------------------------------------
 
+const VIDEO_FILTERS = {
+  WATERMARK: {
+    SIZE_RATIO: 1 / 10,
+    HORIZONTAL_MARGIN_RATIO: 1 / 20,
+    VERTICAL_MARGIN_RATIO: 1 / 20
+  }
+}
+
+// ---------------------------------------------------------------------------
+
 export {
   WEBSERVER,
   API_VERSION,
@@ -893,6 +912,7 @@ export {
   PLUGIN_GLOBAL_CSS_FILE_NAME,
   PLUGIN_GLOBAL_CSS_PATH,
   PRIVATE_RSA_KEY_SIZE,
+  VIDEO_FILTERS,
   ROUTE_CACHE_LIFETIME,
   SORTABLE_COLUMNS,
   HLS_STREAMING_PLAYLIST_DIRECTORY,
index 6e8e47acbf04ef7d9ea286f99185b090dafa0a31..8cd47496ed25b77715ff4a74f04010d3fbd2c539 100644 (file)
@@ -1,8 +1,8 @@
-import * as Sequelize from 'sequelize'
+import { readdir, rename } from 'fs-extra'
 import { join } from 'path'
+import * as Sequelize from 'sequelize'
+import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg/ffprobe-utils'
 import { CONFIG } from '../../initializers/config'
-import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
-import { readdir, rename } from 'fs-extra'
 
 function up (utils: {
   transaction: Sequelize.Transaction
@@ -26,7 +26,7 @@ function up (utils: {
         const uuid = matches[1]
         const ext = matches[2]
 
-        const p = getVideoFileResolution(join(videoFileDir, videoFile))
+        const p = getVideoStreamDimensionsInfo(join(videoFileDir, videoFile))
           .then(async ({ resolution }) => {
             const oldTorrentName = uuid + '.torrent'
             const newTorrentName = uuid + '-' + resolution + '.torrent'
index 985f50587f0b39e2d9aaec43db835f74800e5146..43043315be6ed42dc752d029ca87ebb30ba67de2 100644 (file)
@@ -4,7 +4,7 @@ import { basename, dirname, join } from 'path'
 import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models'
 import { sha256 } from '@shared/extra-utils'
 import { VideoStorage } from '@shared/models'
-import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamSize } from '../helpers/ffprobe-utils'
+import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
 import { logger } from '../helpers/logger'
 import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
 import { generateRandomString } from '../helpers/utils'
@@ -40,10 +40,10 @@ async function updateMasterHLSPlaylist (video: MVideo, playlist: MStreamingPlayl
     const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
 
     await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
-      const size = await getVideoStreamSize(videoFilePath)
+      const size = await getVideoStreamDimensionsInfo(videoFilePath)
 
       const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
-      const resolution = `RESOLUTION=${size.width}x${size.height}`
+      const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
 
       let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
       if (file.fps) line += ',FRAME-RATE=' + file.fps
diff --git a/server/lib/job-queue/handlers/video-edition.ts b/server/lib/job-queue/handlers/video-edition.ts
new file mode 100644 (file)
index 0000000..c5ba045
--- /dev/null
@@ -0,0 +1,229 @@
+import { Job } from 'bull'
+import { move, remove } from 'fs-extra'
+import { join } from 'path'
+import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg'
+import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
+import { CONFIG } from '@server/initializers/config'
+import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
+import { generateWebTorrentVideoFilename } from '@server/lib/paths'
+import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
+import { isAbleToUploadVideo } from '@server/lib/user'
+import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video'
+import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor'
+import { VideoPathManager } from '@server/lib/video-path-manager'
+import { buildNextVideoState } from '@server/lib/video-state'
+import { UserModel } from '@server/models/user/user'
+import { VideoModel } from '@server/models/video/video'
+import { VideoFileModel } from '@server/models/video/video-file'
+import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
+import { getLowercaseExtension, pick } from '@shared/core-utils'
+import {
+  buildFileMetadata,
+  buildUUID,
+  ffprobePromise,
+  getFileSize,
+  getVideoStreamDimensionsInfo,
+  getVideoStreamDuration,
+  getVideoStreamFPS
+} from '@shared/extra-utils'
+import {
+  VideoEditionPayload,
+  VideoEditionTaskPayload,
+  VideoEditorTask,
+  VideoEditorTaskCutPayload,
+  VideoEditorTaskIntroPayload,
+  VideoEditorTaskOutroPayload,
+  VideoEditorTaskWatermarkPayload,
+  VideoState
+} from '@shared/models'
+import { logger, loggerTagsFactory } from '../../../helpers/logger'
+
+const lTagsBase = loggerTagsFactory('video-edition')
+
+async function processVideoEdition (job: Job) {
+  const payload = job.data as VideoEditionPayload
+
+  logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
+
+  const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
+
+  // No video, maybe deleted?
+  if (!video) {
+    logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
+    return undefined
+  }
+
+  await checkUserQuotaOrThrow(video, payload)
+
+  const inputFile = video.getMaxQualityFile()
+
+  const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
+    let tmpInputFilePath: string
+    let outputPath: string
+
+    for (const task of payload.tasks) {
+      const outputFilename = buildUUID() + inputFile.extname
+      outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
+
+      await processTask({
+        inputPath: tmpInputFilePath ?? originalFilePath,
+        video,
+        outputPath,
+        task
+      })
+
+      if (tmpInputFilePath) await remove(tmpInputFilePath)
+
+      // For the next iteration
+      tmpInputFilePath = outputPath
+    }
+
+    return outputPath
+  })
+
+  logger.info('Video edition ended for video %s.', video.uuid)
+
+  const newFile = await buildNewFile(video, editionResultPath)
+
+  const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
+  await move(editionResultPath, outputPath)
+
+  await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
+
+  await removeAllFiles(video, newFile)
+
+  await newFile.save()
+
+  video.state = buildNextVideoState()
+  video.duration = await getVideoStreamDuration(outputPath)
+  await video.save()
+
+  await federateVideoIfNeeded(video, false, undefined)
+
+  if (video.state === VideoState.TO_TRANSCODE) {
+    const user = await UserModel.loadByVideoId(video.id)
+
+    await addOptimizeOrMergeAudioJob(video, newFile, user, false)
+  } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
+    await addMoveToObjectStorageJob(video, false)
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  processVideoEdition
+}
+
+// ---------------------------------------------------------------------------
+
+type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
+  inputPath: string
+  outputPath: string
+  video: MVideo
+  task: T
+}
+
+const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
+  'add-intro': processAddIntroOutro,
+  'add-outro': processAddIntroOutro,
+  'cut': processCut,
+  'add-watermark': processAddWatermark
+}
+
+async function processTask (options: TaskProcessorOptions) {
+  const { video, task } = options
+
+  logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
+
+  const processor = taskProcessors[options.task.name]
+  if (!process) throw new Error('Unknown task ' + task.name)
+
+  return processor(options)
+}
+
+function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
+  const { task } = options
+
+  return addIntroOutro({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    introOutroPath: task.options.file,
+    type: task.name === 'add-intro'
+      ? 'intro'
+      : 'outro',
+
+    availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
+    profile: CONFIG.TRANSCODING.PROFILE
+  })
+}
+
+function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) {
+  const { task } = options
+
+  return cutVideo({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    start: task.options.start,
+    end: task.options.end
+  })
+}
+
+function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
+  const { task } = options
+
+  return addWatermark({
+    ...pick(options, [ 'inputPath', 'outputPath' ]),
+
+    watermarkPath: task.options.file,
+
+    availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
+    profile: CONFIG.TRANSCODING.PROFILE
+  })
+}
+
+async function buildNewFile (video: MVideoId, path: string) {
+  const videoFile = new VideoFileModel({
+    extname: getLowercaseExtension(path),
+    size: await getFileSize(path),
+    metadata: await buildFileMetadata(path),
+    videoStreamingPlaylistId: null,
+    videoId: video.id
+  })
+
+  const probe = await ffprobePromise(path)
+
+  videoFile.fps = await getVideoStreamFPS(path, probe)
+  videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
+
+  videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
+
+  return videoFile
+}
+
+async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
+  const hls = video.getHLSPlaylist()
+
+  if (hls) {
+    await video.removeStreamingPlaylistFiles(hls)
+    await hls.destroy()
+  }
+
+  for (const file of video.VideoFiles) {
+    if (file.id === webTorrentFileException.id) continue
+
+    await video.removeWebTorrentFileAndTorrent(file)
+    await file.destroy()
+  }
+}
+
+async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) {
+  const user = await UserModel.loadByVideoId(video.id)
+
+  const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file
+
+  const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
+  if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
+    throw new Error('Quota exceeded for this user to edit the video')
+  }
+}
index 0d9e80cb86420f299469cd092120c3f6b08aaeee..6b2d603179e577b3ed193cbf0b79a78f153f272c 100644 (file)
@@ -1,18 +1,18 @@
 import { Job } from 'bull'
 import { copy, stat } from 'fs-extra'
-import { getLowercaseExtension } from '@shared/core-utils'
 import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
 import { CONFIG } from '@server/initializers/config'
 import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
 import { generateWebTorrentVideoFilename } from '@server/lib/paths'
 import { addMoveToObjectStorageJob } from '@server/lib/video'
 import { VideoPathManager } from '@server/lib/video-path-manager'
+import { VideoModel } from '@server/models/video/video'
+import { VideoFileModel } from '@server/models/video/video-file'
 import { MVideoFullLight } from '@server/types/models'
+import { getLowercaseExtension } from '@shared/core-utils'
 import { VideoFileImportPayload, VideoStorage } from '@shared/models'
-import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
+import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
 import { logger } from '../../../helpers/logger'
-import { VideoModel } from '../../../models/video/video'
-import { VideoFileModel } from '../../../models/video/video-file'
 
 async function processVideoFileImport (job: Job) {
   const payload = job.data as VideoFileImportPayload
@@ -45,9 +45,9 @@ export {
 // ---------------------------------------------------------------------------
 
 async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
-  const { resolution } = await getVideoFileResolution(inputFilePath)
+  const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath)
   const { size } = await stat(inputFilePath)
-  const fps = await getVideoFileFPS(inputFilePath)
+  const fps = await getVideoStreamFPS(inputFilePath)
 
   const fileExt = getLowercaseExtension(inputFilePath)
 
index b6e05d8f572096b32e1cdb2e50e794f0b143bf95..b3ca28c2f01944ce996e7d04728c665aa4a82629 100644 (file)
@@ -25,7 +25,7 @@ import {
   VideoResolution,
   VideoState
 } from '@shared/models'
-import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
+import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
 import { logger } from '../../../helpers/logger'
 import { getSecureTorrentName } from '../../../helpers/utils'
 import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
@@ -121,10 +121,10 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
 
     const { resolution } = await isAudioFile(tempVideoPath, probe)
       ? { resolution: VideoResolution.H_NOVIDEO }
-      : await getVideoFileResolution(tempVideoPath)
+      : await getVideoStreamDimensionsInfo(tempVideoPath)
 
-    const fps = await getVideoFileFPS(tempVideoPath, probe)
-    const duration = await getDurationFromVideoFile(tempVideoPath, probe)
+    const fps = await getVideoStreamFPS(tempVideoPath, probe)
+    const duration = await getVideoStreamDuration(tempVideoPath, probe)
 
     // Prepare video file object for creation in database
     const fileExt = getLowercaseExtension(tempVideoPath)
index a04cfa2c906a10e0ddb934d0def181eb02e1f232..497f6612a50c8d17e9de358a69f763277f783388 100644 (file)
@@ -1,12 +1,12 @@
 import { Job } from 'bull'
 import { pathExists, readdir, remove } from 'fs-extra'
 import { join } from 'path'
-import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
+import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
 import { VIDEO_LIVE } from '@server/initializers/constants'
 import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
 import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths'
 import { generateVideoMiniature } from '@server/lib/thumbnail'
-import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding'
+import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
 import { VideoPathManager } from '@server/lib/video-path-manager'
 import { moveToNextState } from '@server/lib/video-state'
 import { VideoModel } from '@server/models/video/video'
@@ -96,7 +96,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
     const probe = await ffprobePromise(concatenatedTsFilePath)
     const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
 
-    const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe)
+    const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
 
     const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
       video: videoWithFiles,
@@ -107,7 +107,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
     })
 
     if (!durationDone) {
-      videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
+      videoWithFiles.duration = await getVideoStreamDuration(outputPath)
       await videoWithFiles.save()
 
       durationDone = true
index 5540b791de8efded1d8f193d81d158afb019d3b2..512979734f6e6adc1b9f08442d6c99f4d79d402c 100644 (file)
@@ -1,5 +1,5 @@
 import { Job } from 'bull'
-import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils'
+import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg'
 import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
 import { VideoPathManager } from '@server/lib/video-path-manager'
 import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
@@ -16,7 +16,7 @@ import {
   VideoTranscodingPayload
 } from '@shared/models'
 import { retryTransactionWrapper } from '../../../helpers/database-utils'
-import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils'
+import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg'
 import { logger, loggerTagsFactory } from '../../../helpers/logger'
 import { CONFIG } from '../../../initializers/config'
 import { VideoModel } from '../../../models/video/video'
@@ -25,7 +25,7 @@ import {
   mergeAudioVideofile,
   optimizeOriginalVideofile,
   transcodeNewWebTorrentResolution
-} from '../../transcoding/video-transcoding'
+} from '../../transcoding/transcoding'
 
 type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
 
@@ -174,10 +174,10 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
 async function onVideoFirstWebTorrentTranscoding (
   videoArg: MVideoWithFile,
   payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload,
-  transcodeType: TranscodeOptionsType,
+  transcodeType: TranscodeVODOptionsType,
   user: MUserId
 ) {
-  const { resolution, isPortraitMode, audioStream } = await videoArg.getMaxQualityFileInfo()
+  const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile()
 
   // Maybe the video changed in database, refresh it
   const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid)
index 22bd1f5d2c70b3d8a8228aef440f4c70d7f7d552..e10a3bab5df56e6592dde3e032b20ea34bc0c22b 100644 (file)
@@ -14,6 +14,7 @@ import {
   JobType,
   MoveObjectStoragePayload,
   RefreshPayload,
+  VideoEditionPayload,
   VideoFileImportPayload,
   VideoImportPayload,
   VideoLiveEndingPayload,
@@ -31,6 +32,7 @@ import { refreshAPObject } from './handlers/activitypub-refresher'
 import { processActorKeys } from './handlers/actor-keys'
 import { processEmail } from './handlers/email'
 import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
+import { processVideoEdition } from './handlers/video-edition'
 import { processVideoFileImport } from './handlers/video-file-import'
 import { processVideoImport } from './handlers/video-import'
 import { processVideoLiveEnding } from './handlers/video-live-ending'
@@ -53,6 +55,7 @@ type CreateJobArgument =
   { type: 'actor-keys', payload: ActorKeysPayload } |
   { type: 'video-redundancy', payload: VideoRedundancyPayload } |
   { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
+  { type: 'video-edition', payload: VideoEditionPayload } |
   { type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
 
 export type CreateJobOptions = {
@@ -75,7 +78,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
   'video-live-ending': processVideoLiveEnding,
   'actor-keys': processActorKeys,
   'video-redundancy': processVideoRedundancy,
-  'move-to-object-storage': processMoveToObjectStorage
+  'move-to-object-storage': processMoveToObjectStorage,
+  'video-edition': processVideoEdition
 }
 
 const jobTypes: JobType[] = [
@@ -93,7 +97,8 @@ const jobTypes: JobType[] = [
   'video-redundancy',
   'actor-keys',
   'video-live-ending',
-  'move-to-object-storage'
+  'move-to-object-storage',
+  'video-edition'
 ]
 
 class JobQueue {
index 33e49acc1b9a9c4d94573f9c88db3d3a5c599c7a..21c34a9a4febba0441e2f833ea9f1c82f5a90664 100644 (file)
@@ -5,10 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
 import {
   computeLowerResolutionsToTranscode,
   ffprobePromise,
-  getVideoFileBitrate,
-  getVideoFileFPS,
-  getVideoFileResolution
-} from '@server/helpers/ffprobe-utils'
+  getVideoStreamBitrate,
+  getVideoStreamFPS,
+  getVideoStreamDimensionsInfo
+} from '@server/helpers/ffmpeg'
 import { logger, loggerTagsFactory } from '@server/helpers/logger'
 import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
 import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
@@ -226,9 +226,9 @@ class LiveManager {
     const probe = await ffprobePromise(inputUrl)
 
     const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([
-      getVideoFileResolution(inputUrl, probe),
-      getVideoFileFPS(inputUrl, probe),
-      getVideoFileBitrate(inputUrl, probe)
+      getVideoStreamDimensionsInfo(inputUrl, probe),
+      getVideoStreamFPS(inputUrl, probe),
+      getVideoStreamBitrate(inputUrl, probe)
     ])
 
     logger.info(
index 22a47942a41c5dd0b236e392b7234d2b97a8ed40..f5f4730393a1a7ad55cc6881f4e3a05cb3a08264 100644 (file)
@@ -5,14 +5,14 @@ import { FfmpegCommand } from 'fluent-ffmpeg'
 import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
 import { basename, join } from 'path'
 import { EventEmitter } from 'stream'
-import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils'
+import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg'
 import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
 import { CONFIG } from '@server/initializers/config'
 import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
 import { VideoFileModel } from '@server/models/video/video-file'
 import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
 import { getLiveDirectory } from '../../paths'
-import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles'
+import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles'
 import { isAbleToUploadVideo } from '../../user'
 import { LiveQuotaStore } from '../live-quota-store'
 import { LiveSegmentShaStore } from '../live-segment-sha-store'
index 78e4a28ad6447cc11971903ab1680e3d9cc50746..897271c0b416a751062e6c5fc29116d0acf255fa 100644 (file)
@@ -1,6 +1,6 @@
 import express from 'express'
 import { join } from 'path'
-import { ffprobePromise } from '@server/helpers/ffprobe-utils'
+import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils'
 import { buildLogger } from '@server/helpers/logger'
 import { CONFIG } from '@server/initializers/config'
 import { WEBSERVER } from '@server/initializers/constants'
index d1756040a858bff020cbf9efa581cee6e4d4e018..f4d40567611c9c4c7572a3bafc3bdca3a8f523ed 100644 (file)
@@ -21,7 +21,7 @@ import {
   VideoPlaylistPrivacy,
   VideoPrivacy
 } from '@shared/models'
-import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles'
+import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles'
 import { buildPluginHelpers } from './plugin-helpers-builder'
 
 export class RegisterHelpers {
index d97f21eb7d84d7fb994bba8383d93a609ad9a468..38512f384b80209f695915688f1d3fb422b3ac0a 100644 (file)
@@ -8,7 +8,7 @@ import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuth
 import { Hooks } from './plugins/hooks'
 import { PluginManager } from './plugins/plugin-manager'
 import { getThemeOrDefault } from './plugins/theme-utils'
-import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
+import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles'
 
 /**
  *
@@ -151,6 +151,9 @@ class ServerConfigManager {
           port: CONFIG.LIVE.RTMP.PORT
         }
       },
+      videoEditor: {
+        enabled: CONFIG.VIDEO_EDITOR.ENABLED
+      },
       import: {
         videos: {
           http: {
index 36270e5c13aad8a0bb12196c6f1989e1f16fa739..aa2d7a8132923a77e0b0a1eaa9f34c0d861e0c3e 100644 (file)
@@ -1,7 +1,6 @@
 import { join } from 'path'
-import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
-import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
-import { generateImageFilename, processImage } from '../helpers/image-utils'
+import { ThumbnailType } from '@shared/models'
+import { generateImageFilename, generateImageFromVideoFile, processImage } from '../helpers/image-utils'
 import { downloadImage } from '../helpers/requests'
 import { CONFIG } from '../initializers/config'
 import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
similarity index 91%
rename from server/lib/transcoding/video-transcoding-profiles.ts
rename to server/lib/transcoding/default-transcoding-profiles.ts
index dcc8d4c5cdb87cf6612606a6fbf4d0d5aca8026b..ba98a11ca3b47c5142f3a77f5985a7967c9c0ae3 100644 (file)
@@ -2,8 +2,14 @@
 import { logger } from '@server/helpers/logger'
 import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
 import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
-import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils'
-import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '../../helpers/ffprobe-utils'
+import {
+  buildStreamSuffix,
+  canDoQuickAudioTranscode,
+  ffprobePromise,
+  getAudioStream,
+  getMaxAudioBitrate,
+  resetSupportedEncoders
+} from '../../helpers/ffmpeg'
 
 /**
  *
@@ -15,8 +21,14 @@ import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBi
  *  * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
  */
 
+// ---------------------------------------------------------------------------
+// Default builders
+// ---------------------------------------------------------------------------
+
 const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
   const { fps, inputRatio, inputBitrate, resolution } = options
+
+  // TODO: remove in 4.2, fps is not optional anymore
   if (!fps) return { outputOptions: [ ] }
 
   const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
@@ -45,10 +57,10 @@ const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOp
   }
 }
 
-const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum }) => {
+const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => {
   const probe = await ffprobePromise(input)
 
-  if (await canDoQuickAudioTranscode(input, probe)) {
+  if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) {
     logger.debug('Copy audio stream %s by AAC encoder.', input)
     return { copy: true, outputOptions: [ ] }
   }
@@ -75,7 +87,10 @@ const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum })
   return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
 }
 
-// Used to get and update available encoders
+// ---------------------------------------------------------------------------
+// Profile manager to get and change default profiles
+// ---------------------------------------------------------------------------
+
 class VideoTranscodingProfilesManager {
   private static instance: VideoTranscodingProfilesManager
 
similarity index 92%
rename from server/lib/transcoding/video-transcoding.ts
rename to server/lib/transcoding/transcoding.ts
index 9942a067b08e40d75aea435524e0214e4dd9fe28..d55364e25c4baace27e9d163f0216eaa54a9a275 100644 (file)
@@ -6,8 +6,15 @@ import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
 import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
 import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
 import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
-import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils'
-import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils'
+import {
+  canDoQuickTranscode,
+  getVideoStreamDuration,
+  buildFileMetadata,
+  getVideoStreamFPS,
+  transcodeVOD,
+  TranscodeVODOptions,
+  TranscodeVODOptionsType
+} from '../../helpers/ffmpeg'
 import { CONFIG } from '../../initializers/config'
 import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
 import { VideoFileModel } from '../../models/video/video-file'
@@ -21,7 +28,7 @@ import {
   getHlsResolutionPlaylistFilename
 } from '../paths'
 import { VideoPathManager } from '../video-path-manager'
-import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
+import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
 
 /**
  *
@@ -38,13 +45,13 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
   return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => {
     const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
 
-    const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath)
+    const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
       ? 'quick-transcode'
       : 'video'
 
     const resolution = toEven(inputVideoFile.resolution)
 
-    const transcodeOptions: TranscodeOptions = {
+    const transcodeOptions: TranscodeVODOptions = {
       type: transcodeType,
 
       inputPath: videoInputPath,
@@ -59,7 +66,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
     }
 
     // Could be very long!
-    await transcode(transcodeOptions)
+    await transcodeVOD(transcodeOptions)
 
     // Important to do this before getVideoFilename() to take in account the new filename
     inputVideoFile.extname = newExtname
@@ -121,7 +128,7 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
         job
       }
 
-    await transcode(transcodeOptions)
+    await transcodeVOD(transcodeOptions)
 
     return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
   })
@@ -158,7 +165,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
     }
 
     try {
-      await transcode(transcodeOptions)
+      await transcodeVOD(transcodeOptions)
 
       await remove(audioInputPath)
       await remove(tmpPreviewPath)
@@ -175,7 +182,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
     const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile)
     // ffmpeg generated a new video file, so update the video duration
     // See https://trac.ffmpeg.org/ticket/5456
-    video.duration = await getDurationFromVideoFile(videoTranscodedPath)
+    video.duration = await getVideoStreamDuration(videoTranscodedPath)
     await video.save()
 
     return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
@@ -239,8 +246,8 @@ async function onWebTorrentVideoFileTranscoding (
   outputPath: string
 ) {
   const stats = await stat(transcodingPath)
-  const fps = await getVideoFileFPS(transcodingPath)
-  const metadata = await getMetadataFromFile(transcodingPath)
+  const fps = await getVideoStreamFPS(transcodingPath)
+  const metadata = await buildFileMetadata(transcodingPath)
 
   await move(transcodingPath, outputPath, { overwrite: true })
 
@@ -299,7 +306,7 @@ async function generateHlsPlaylistCommon (options: {
     job
   }
 
-  await transcode(transcodeOptions)
+  await transcodeVOD(transcodeOptions)
 
   // Create or update the playlist
   const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
@@ -344,8 +351,8 @@ async function generateHlsPlaylistCommon (options: {
   const stats = await stat(videoFilePath)
 
   newVideoFile.size = stats.size
-  newVideoFile.fps = await getVideoFileFPS(videoFilePath)
-  newVideoFile.metadata = await getMetadataFromFile(videoFilePath)
+  newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
+  newVideoFile.metadata = await buildFileMetadata(videoFilePath)
 
   await createTorrentAndSetInfoHash(playlist, newVideoFile)
 
index 0d292ac90abcc0bf642db88c79e8b60d9df60d24..3f749929675db1bad4e6951f289471cef6460bf6 100644 (file)
@@ -19,6 +19,7 @@ import { buildActorInstance } from './local-actor'
 import { Redis } from './redis'
 import { createLocalVideoChannel } from './video-channel'
 import { createWatchLaterPlaylist } from './video-playlist'
+import { logger } from '@server/helpers/logger'
 
 type ChannelNames = { name: string, displayName: string }
 
@@ -159,6 +160,11 @@ async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
   const uploadedTotal = newVideoSize + totalBytes
   const uploadedDaily = newVideoSize + totalBytesDaily
 
+  logger.debug(
+    'Check user %d quota to upload another video.', userId,
+    { totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, newVideoSize }
+  )
+
   if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
   if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
 
diff --git a/server/lib/video-editor.ts b/server/lib/video-editor.ts
new file mode 100644 (file)
index 0000000..99b0bd9
--- /dev/null
@@ -0,0 +1,32 @@
+import { MVideoFullLight } from "@server/types/models"
+import { getVideoStreamDuration } from "@shared/extra-utils"
+import { VideoEditorTask } from "@shared/models"
+
+function buildTaskFileFieldname (indice: number, fieldName = 'file') {
+  return `tasks[${indice}][options][${fieldName}]`
+}
+
+function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') {
+  return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
+}
+
+async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoEditorTask[], fileFinder: (i: number) => string) {
+  let additionalDuration = 0
+
+  for (let i = 0; i < tasks.length; i++) {
+    const task = tasks[i]
+
+    if (task.name !== 'add-intro' && task.name !== 'add-outro') continue
+
+    const filePath = fileFinder(i)
+    additionalDuration += await getVideoStreamDuration(filePath)
+  }
+
+  return (video.getMaxQualityFile().size / video.duration) * additionalDuration
+}
+
+export {
+  approximateIntroOutroAdditionalSize,
+  buildTaskFileFieldname,
+  getTaskFile
+}
index 2690f953d9a54a6886dbbf88febeb959e1e64048..ec4256c1aa077b0efabb373c32f30badab9feb95 100644 (file)
@@ -81,7 +81,7 @@ async function setVideoTags (options: {
   video.Tags = tagInstances
 }
 
-async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId) {
+async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId, isNewVideo = true) {
   let dataInput: VideoTranscodingPayload
 
   if (videoFile.isAudio()) {
@@ -90,13 +90,13 @@ async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoF
       resolution: DEFAULT_AUDIO_RESOLUTION,
       videoUUID: video.uuid,
       createHLSIfNeeded: true,
-      isNewVideo: true
+      isNewVideo
     }
   } else {
     dataInput = {
       type: 'optimize-to-webtorrent',
       videoUUID: video.uuid,
-      isNewVideo: true
+      isNewVideo
     }
   }
 
index 8b14feb3c336f721a9ba406c013d60b871a845f9..e87b2e39d765d5261e3b1dabd0de1d24ecc130ff 100644 (file)
@@ -57,6 +57,8 @@ const customConfigUpdateValidator = [
   body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
   body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
 
+  body('videoEditor.enabled').isBoolean().withMessage('Should have a valid video editor enabled boolean'),
+
   body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'),
   body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
   body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
@@ -104,6 +106,7 @@ const customConfigUpdateValidator = [
     if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
     if (!checkInvalidTranscodingConfig(req.body, res)) return
     if (!checkInvalidLiveConfig(req.body, res)) return
+    if (!checkInvalidVideoEditorConfig(req.body, res)) return
 
     return next()
   }
@@ -159,3 +162,14 @@ function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Respon
 
   return true
 }
+
+function checkInvalidVideoEditorConfig (customConfig: CustomConfig, res: express.Response) {
+  if (customConfig.videoEditor.enabled === false) return true
+
+  if (customConfig.videoEditor.enabled === true && customConfig.transcoding.enabled === false) {
+    res.fail({ message: 'You cannot enable video editor if transcoding is not enabled' })
+    return false
+  }
+
+  return true
+}
index 104eace912aed746b1d7c0054e86fc0228d75a89..410de4d80a04e71199e327f6faf52cf7ade83d9e 100644 (file)
@@ -8,6 +8,7 @@ function areValidationErrors (req: express.Request, res: express.Response) {
 
   if (!errors.isEmpty()) {
     logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
+
     res.fail({
       message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
       instance: req.originalUrl,
index fc978b63ad443e3a80d143dcec84c52d42985cc9..8807435f6c9b6d3cb274564fd9a1b170ad4a88f7 100644 (file)
@@ -1,5 +1,6 @@
 import { Request, Response } from 'express'
 import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
+import { isAbleToUploadVideo } from '@server/lib/user'
 import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
 import { VideoModel } from '@server/models/video/video'
 import { VideoChannelModel } from '@server/models/video/video-channel'
@@ -7,6 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file'
 import {
   MUser,
   MUserAccountId,
+  MUserId,
   MVideo,
   MVideoAccountLight,
   MVideoFormattableDetails,
@@ -16,7 +18,7 @@ import {
   MVideoThumbnail,
   MVideoWithRights
 } from '@server/types/models'
-import { HttpStatusCode, UserRight } from '@shared/models'
+import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models'
 
 async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
   const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -108,6 +110,11 @@ async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVid
 
   // Only the owner or a user that have blocklist rights can see the video
   if (!user || !user.canGetVideo(video)) {
+    res.fail({
+      status: HttpStatusCode.FORBIDDEN_403,
+      message: 'Cannot fetch information of private/internal/blocklisted video'
+    })
+
     return false
   }
 
@@ -139,13 +146,28 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
   return true
 }
 
+async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) {
+  if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
+    res.fail({
+      status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
+      message: 'The user video quota is exceeded with this video.',
+      type: ServerErrorCode.QUOTA_REACHED
+    })
+    return false
+  }
+
+  return true
+}
+
 // ---------------------------------------------------------------------------
 
 export {
   doesVideoChannelOfAccountExist,
   doesVideoExist,
   doesVideoFileOfVideoExist,
+
   checkUserCanManageVideo,
   checkCanSeeVideoIfPrivate,
-  checkCanSeePrivateVideo
+  checkCanSeePrivateVideo,
+  checkUserQuota
 }
index f365d8ee19b17775c49a5e90d155ab59b7c790c0..faa0825104bc4ff22c893a9c7941e5e874362ebc 100644 (file)
@@ -2,6 +2,7 @@ export * from './video-blacklist'
 export * from './video-captions'
 export * from './video-channels'
 export * from './video-comments'
+export * from './video-editor'
 export * from './video-files'
 export * from './video-imports'
 export * from './video-live'
index a399871e194a81ea52c3a4b9fa2fcacf593c04b2..441c6b4be2554c8117eca74a7814efb81105a603 100644 (file)
@@ -1,6 +1,6 @@
 import express from 'express'
 import { body, param } from 'express-validator'
-import { HttpStatusCode, UserRight } from '@shared/models'
+import { UserRight } from '@shared/models'
 import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
@@ -74,13 +74,7 @@ const listVideoCaptionsValidator = [
     if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
 
     const video = res.locals.onlyVideo
-
-    if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: 'Cannot list captions of private/internal/blocklisted video'
-      })
-    }
+    if (!await checkCanSeeVideoIfPrivate(req, res, video)) return
 
     return next()
   }
index 91e85711d3b761962e822310d9d5540994cb048a..96d956035b802947b88c855e7d4eefea613ffe01 100644 (file)
@@ -54,12 +54,7 @@ const listVideoCommentThreadsValidator = [
     if (areValidationErrors(req, res)) return
     if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
 
-    if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: 'Cannot list comments of private/internal/blocklisted video'
-      })
-    }
+    if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
 
     return next()
   }
@@ -78,12 +73,7 @@ const listVideoThreadCommentsValidator = [
     if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
     if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
 
-    if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: 'Cannot list threads of private/internal/blocklisted video'
-      })
-    }
+    if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
 
     return next()
   }
diff --git a/server/middlewares/validators/videos/video-editor.ts b/server/middlewares/validators/videos/video-editor.ts
new file mode 100644 (file)
index 0000000..9be97be
--- /dev/null
@@ -0,0 +1,112 @@
+import express from 'express'
+import { body, param } from 'express-validator'
+import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc'
+import {
+  isEditorCutTaskValid,
+  isEditorTaskAddIntroOutroValid,
+  isEditorTaskAddWatermarkValid,
+  isValidEditorTasksArray
+} from '@server/helpers/custom-validators/video-editor'
+import { cleanUpReqFiles } from '@server/helpers/express-utils'
+import { CONFIG } from '@server/initializers/config'
+import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-editor'
+import { isAudioFile } from '@shared/extra-utils'
+import { HttpStatusCode, UserRight, VideoEditorCreateEdition, VideoEditorTask, VideoState } from '@shared/models'
+import { logger } from '../../../helpers/logger'
+import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
+
+const videosEditorAddEditionValidator = [
+  param('videoId').custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
+
+  body('tasks').custom(isValidEditorTasksArray).withMessage('Should have a valid array of tasks'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videosEditorAddEditionValidator parameters.', { parameters: req.params, body: req.body, files: req.files })
+
+    if (CONFIG.VIDEO_EDITOR.ENABLED !== true) {
+      res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: 'Video editor is disabled on this instance'
+      })
+
+      return cleanUpReqFiles(req)
+    }
+
+    if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
+
+    const body: VideoEditorCreateEdition = req.body
+    const files = req.files as Express.Multer.File[]
+
+    for (let i = 0; i < body.tasks.length; i++) {
+      const task = body.tasks[i]
+
+      if (!checkTask(req, task, i)) {
+        res.fail({
+          status: HttpStatusCode.BAD_REQUEST_400,
+          message: `Task ${task.name} is invalid`
+        })
+
+        return cleanUpReqFiles(req)
+      }
+
+      if (task.name === 'add-intro' || task.name === 'add-outro') {
+        const filePath = getTaskFile(files, i).path
+
+        // Our concat filter needs a video stream
+        if (await isAudioFile(filePath)) {
+          res.fail({
+            status: HttpStatusCode.BAD_REQUEST_400,
+            message: `Task ${task.name} is invalid: file does not contain a video stream`
+          })
+
+          return cleanUpReqFiles(req)
+        }
+      }
+    }
+
+    if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
+
+    const video = res.locals.videoAll
+    if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
+      res.fail({
+        status: HttpStatusCode.CONFLICT_409,
+        message: 'Cannot edit video that is already waiting for transcoding/edition'
+      })
+
+      return cleanUpReqFiles(req)
+    }
+
+    const user = res.locals.oauth.token.User
+    if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
+
+    // Try to make an approximation of bytes added by the intro/outro
+    const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFile(files, i).path)
+    if (await checkUserQuota(user, additionalBytes, res) === false) return cleanUpReqFiles(req)
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  videosEditorAddEditionValidator
+}
+
+// ---------------------------------------------------------------------------
+
+const taskCheckers: {
+  [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => boolean
+} = {
+  'cut': isEditorCutTaskValid,
+  'add-intro': isEditorTaskAddIntroOutroValid,
+  'add-outro': isEditorTaskAddIntroOutroValid,
+  'add-watermark': isEditorTaskAddWatermarkValid
+}
+
+function checkTask (req: express.Request, task: VideoEditorTask, indice?: number) {
+  const checker = taskCheckers[task.name]
+  if (!checker) return false
+
+  return checker(task, indice, req.files as Express.Multer.File[])
+}
index 95e4cebced7506266f52b72205a7e70cf2bbff13..6dcdc05f5c72cdbc7673eb7857d7b01b2244d206 100644 (file)
@@ -3,20 +3,13 @@ import { param } from 'express-validator'
 import { isIdValid } from '@server/helpers/custom-validators/misc'
 import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
 import { logger } from '@server/helpers/logger'
-import { isAbleToUploadVideo } from '@server/lib/user'
 import { AccountModel } from '@server/models/account/account'
 import { MVideoWithAllFiles } from '@server/types/models'
-import {
-  HttpStatusCode,
-  ServerErrorCode,
-  UserRight,
-  VideoChangeOwnershipAccept,
-  VideoChangeOwnershipStatus,
-  VideoState
-} from '@shared/models'
+import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models'
 import {
   areValidationErrors,
   checkUserCanManageVideo,
+  checkUserQuota,
   doesChangeVideoOwnershipExist,
   doesVideoChannelOfAccountExist,
   doesVideoExist,
@@ -113,15 +106,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response)
 
   const user = res.locals.oauth.token.User
 
-  if (!await isAbleToUploadVideo(user.id, video.getMaxQualityFile().size)) {
-    res.fail({
-      status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
-      message: 'The user video quota is exceeded with this video.',
-      type: ServerErrorCode.QUOTA_REACHED
-    })
-
-    return false
-  }
+  if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false
 
   return true
 }
index f5fee845ef5db29af317a9d62a277eff5e03b11e..241b9ed7b2eb2357151a60e8b0ab5e53244ae767 100644 (file)
@@ -27,7 +27,7 @@ import {
   isVideoPlaylistTimestampValid,
   isVideoPlaylistTypeValid
 } from '../../../helpers/custom-validators/video-playlists'
-import { isVideoImage } from '../../../helpers/custom-validators/videos'
+import { isVideoImageValid } from '../../../helpers/custom-validators/videos'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
@@ -390,7 +390,7 @@ export {
 function getCommonPlaylistEditAttributes () {
   return [
     body('thumbnailfile')
-      .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile'))
+      .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
       .withMessage(
         'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
         CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
index b3ffb70079b23c33e82763ac6258e15ae07643d4..26597cf7b7865662b3a73b9fc5671a24230f9c59 100644 (file)
@@ -3,7 +3,6 @@ import { body, header, param, query, ValidationChain } from 'express-validator'
 import { isTestInstance } from '@server/helpers/core-utils'
 import { getResumableUploadPath } from '@server/helpers/upload'
 import { Redis } from '@server/lib/redis'
-import { isAbleToUploadVideo } from '@server/lib/user'
 import { getServerActor } from '@server/models/application/application'
 import { ExpressPromiseHandler } from '@server/types/express-handler'
 import { MUserAccountId, MVideoFullLight } from '@server/types/models'
@@ -13,7 +12,7 @@ import {
   exists,
   isBooleanValid,
   isDateValid,
-  isFileFieldValid,
+  isFileValid,
   isIdValid,
   isUUIDValid,
   toArray,
@@ -23,24 +22,24 @@ import {
 } from '../../../helpers/custom-validators/misc'
 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
 import {
+  areVideoTagsValid,
   isScheduleVideoUpdatePrivacyValid,
   isVideoCategoryValid,
   isVideoDescriptionValid,
   isVideoFileMimeTypeValid,
   isVideoFileSizeValid,
   isVideoFilterValid,
-  isVideoImage,
+  isVideoImageValid,
   isVideoIncludeValid,
   isVideoLanguageValid,
   isVideoLicenceValid,
   isVideoNameValid,
   isVideoOriginallyPublishedAtValid,
   isVideoPrivacyValid,
-  isVideoSupportValid,
-  isVideoTagsValid
+  isVideoSupportValid
 } from '../../../helpers/custom-validators/videos'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
-import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
+import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
 import { logger } from '../../../helpers/logger'
 import { deleteFileAndCatch } from '../../../helpers/utils'
 import { getVideoWithAttributes } from '../../../helpers/video'
@@ -53,6 +52,7 @@ import {
   areValidationErrors,
   checkCanSeePrivateVideo,
   checkUserCanManageVideo,
+  checkUserQuota,
   doesVideoChannelOfAccountExist,
   doesVideoExist,
   doesVideoFileOfVideoExist,
@@ -61,7 +61,7 @@ import {
 
 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
   body('videofile')
-    .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
+    .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
     .withMessage('Should have a file'),
   body('name')
     .trim()
@@ -299,12 +299,11 @@ const videosCustomGetValidator = (
 
       // Video private or blacklisted
       if (video.requiresAuth()) {
-        if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next()
+        if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) {
+          return next()
+        }
 
-        return res.fail({
-          status: HttpStatusCode.FORBIDDEN_403,
-          message: 'Cannot get this private/internal or blocklisted video'
-        })
+        return
       }
 
       // Video is public, anyone can access it
@@ -375,12 +374,12 @@ const videosOverviewValidator = [
 function getCommonVideoEditAttributes () {
   return [
     body('thumbnailfile')
-      .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
+      .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
         'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
         CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
       ),
     body('previewfile')
-      .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
+      .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
         'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
         CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
       ),
@@ -420,7 +419,7 @@ function getCommonVideoEditAttributes () {
     body('tags')
       .optional()
       .customSanitizer(toValueOrNull)
-      .custom(isVideoTagsValid)
+      .custom(areVideoTagsValid)
       .withMessage(
         `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
         `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
@@ -612,14 +611,7 @@ async function commonVideoChecksPass (parameters: {
     return false
   }
 
-  if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
-    res.fail({
-      status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
-      message: 'The user video quota is exceeded with this video.',
-      type: ServerErrorCode.QUOTA_REACHED
-    })
-    return false
-  }
+  if (await checkUserQuota(user, videoFileSize, res) === false) return false
 
   return true
 }
@@ -654,7 +646,7 @@ export async function isVideoAccepted (
 }
 
 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
-  const duration: number = await getDurationFromVideoFile(videoFile.path)
+  const duration: number = await getVideoStreamDuration(videoFile.path)
 
   if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
 
index 5536334ebde7b9e88da8cce3529319f5a1af0e50..a4093ce3ba9aba83f2cf0f0d3b0c23be6644b99e 100644 (file)
@@ -61,7 +61,7 @@ import {
   isVideoStateValid,
   isVideoSupportValid
 } from '../../helpers/custom-validators/videos'
-import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
+import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
 import { logger } from '../../helpers/logger'
 import { CONFIG } from '../../initializers/config'
 import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
@@ -1683,7 +1683,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
     return peertubeTruncate(this.description, { length: maxLength })
   }
 
-  getMaxQualityFileInfo () {
+  probeMaxQualityFile () {
     const file = this.getMaxQualityFile()
     const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
 
@@ -1695,7 +1695,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
       return {
         audioStream,
 
-        ...await getVideoFileResolution(originalFilePath, probe)
+        ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
       }
     })
   }
index 71e1c40ba6d5aac4c90f178045b16abdfa523603..bb81d4565f5b1f14ba9ca4dff39f5b6328e0aa1d 100644 (file)
@@ -25,12 +25,16 @@ describe('Test AP refresher', function () {
   before(async function () {
     this.timeout(60000)
 
-    servers = await createMultipleServers(2, { transcoding: { enabled: false } })
+    servers = await createMultipleServers(2)
 
     // Get the access tokens
     await setAccessTokensToServers(servers)
     await setDefaultVideoChannel(servers)
 
+    for (const server of servers) {
+      await server.config.disableTranscoding()
+    }
+
     {
       videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid
       videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid
index 3cccb612a05b744850c0e55ac953ec8c5ce844da..ce067a8924a808eeeebe400fd33ba3ef6084b4f1 100644 (file)
@@ -145,6 +145,9 @@ describe('Test config API validators', function () {
         }
       }
     },
+    videoEditor: {
+      enabled: true
+    },
     import: {
       videos: {
         concurrency: 1,
index e052296dbb0561ffa8642963f5c92745b4c70b39..c088b52cd7b4bb45c4929bf5be13114f7db5f4c0 100644 (file)
@@ -25,6 +25,7 @@ import './video-blacklist'
 import './video-captions'
 import './video-channels'
 import './video-comments'
+import './video-editor'
 import './video-imports'
 import './video-playlists'
 import './videos'
diff --git a/server/tests/api/check-params/video-editor.ts b/server/tests/api/check-params/video-editor.ts
new file mode 100644 (file)
index 0000000..db284a3
--- /dev/null
@@ -0,0 +1,385 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import { HttpStatusCode, VideoEditorTask } from '@shared/models'
+import {
+  cleanupTests,
+  createSingleServer,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  VideoEditorCommand,
+  waitJobs
+} from '@shared/server-commands'
+
+describe('Test video editor API validator', function () {
+  let server: PeerTubeServer
+  let command: VideoEditorCommand
+  let userAccessToken: string
+  let videoUUID: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(120_000)
+
+    server = await createSingleServer(1)
+
+    await setAccessTokensToServers([ server ])
+    userAccessToken = await server.users.generateUserAndToken('user1')
+
+    await server.config.enableMinimumTranscoding()
+
+    const { uuid } = await server.videos.quickUpload({ name: 'video' })
+    videoUUID = uuid
+
+    command = server.videoEditor
+
+    await waitJobs([ server ])
+  })
+
+  describe('Task creation', function () {
+
+    describe('Config settings', function () {
+
+      it('Should fail if editor is disabled', async function () {
+        await server.config.updateExistingSubConfig({
+          newConfig: {
+            videoEditor: {
+              enabled: false
+            }
+          }
+        })
+
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should fail to enable editor if transcoding is disabled', async function () {
+        await server.config.updateExistingSubConfig({
+          newConfig: {
+            videoEditor: {
+              enabled: true
+            },
+            transcoding: {
+              enabled: false
+            }
+          },
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should succeed to enable video editor', async function () {
+        await server.config.updateExistingSubConfig({
+          newConfig: {
+            videoEditor: {
+              enabled: true
+            },
+            transcoding: {
+              enabled: true
+            }
+          }
+        })
+      })
+    })
+
+    describe('Common tasks', function () {
+
+      it('Should fail without token', async function () {
+        await command.createEditionTasks({
+          token: null,
+          videoId: videoUUID,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+        })
+      })
+
+      it('Should fail with another user token', async function () {
+        await command.createEditionTasks({
+          token: userAccessToken,
+          videoId: videoUUID,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.FORBIDDEN_403
+        })
+      })
+
+      it('Should fail with an invalid video', async function () {
+        await command.createEditionTasks({
+          videoId: 'tintin',
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should fail with an unknown video', async function () {
+        await command.createEditionTasks({
+          videoId: 42,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.NOT_FOUND_404
+        })
+      })
+
+      it('Should fail with an already in transcoding state video', async function () {
+        await server.jobs.pauseJobQueue()
+
+        const { uuid } = await server.videos.quickUpload({ name: 'transcoded video' })
+
+        await command.createEditionTasks({
+          videoId: uuid,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.CONFLICT_409
+        })
+
+        await server.jobs.resumeJobQueue()
+      })
+
+      it('Should fail with a bad complex task', async function () {
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: [
+            {
+              name: 'cut',
+              options: {
+                start: 1,
+                end: 2
+              }
+            },
+            {
+              name: 'hadock',
+              options: {
+                start: 1,
+                end: 2
+              }
+            }
+          ] as any,
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should fail without task', async function () {
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: [],
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should fail with too many tasks', async function () {
+        const tasks: VideoEditorTask[] = []
+
+        for (let i = 0; i < 110; i++) {
+          tasks.push({
+            name: 'cut',
+            options: {
+              start: 1
+            }
+          })
+        }
+
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks,
+          expectedStatus: HttpStatusCode.BAD_REQUEST_400
+        })
+      })
+
+      it('Should succeed with correct parameters', async function () {
+        await server.jobs.pauseJobQueue()
+
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.NO_CONTENT_204
+        })
+      })
+
+      it('Should fail with a video that is already waiting for edition', async function () {
+        this.timeout(120000)
+
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: VideoEditorCommand.getComplexTask(),
+          expectedStatus: HttpStatusCode.CONFLICT_409
+        })
+
+        await server.jobs.resumeJobQueue()
+
+        await waitJobs([ server ])
+      })
+    })
+
+    describe('Cut task', function () {
+
+      async function cut (start: number, end: number, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: [
+            {
+              name: 'cut',
+              options: {
+                start,
+                end
+              }
+            }
+          ],
+          expectedStatus
+        })
+      }
+
+      it('Should fail with bad start/end', async function () {
+        const invalid = [
+          'tintin',
+          -1,
+          undefined
+        ]
+
+        for (const value of invalid) {
+          await cut(value as any, undefined)
+          await cut(undefined, value as any)
+        }
+      })
+
+      it('Should fail with the same start/end', async function () {
+        await cut(2, 2)
+      })
+
+      it('Should fail with inconsistents start/end', async function () {
+        await cut(2, 1)
+      })
+
+      it('Should fail without start and end', async function () {
+        await cut(undefined, undefined)
+      })
+
+      it('Should succeed with the correct params', async function () {
+        this.timeout(120000)
+
+        await cut(0, 2, HttpStatusCode.NO_CONTENT_204)
+
+        await waitJobs([ server ])
+      })
+    })
+
+    describe('Watermark task', function () {
+
+      async function addWatermark (file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: [
+            {
+              name: 'add-watermark',
+              options: {
+                file
+              }
+            }
+          ],
+          expectedStatus
+        })
+      }
+
+      it('Should fail without waterkmark', async function () {
+        await addWatermark(undefined)
+      })
+
+      it('Should fail with an invalid watermark', async function () {
+        await addWatermark('video_short.mp4')
+      })
+
+      it('Should succeed with the correct params', async function () {
+        this.timeout(120000)
+
+        await addWatermark('thumbnail.jpg', HttpStatusCode.NO_CONTENT_204)
+
+        await waitJobs([ server ])
+      })
+    })
+
+    describe('Intro/Outro task', function () {
+
+      async function addIntroOutro (type: 'add-intro' | 'add-outro', file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
+        await command.createEditionTasks({
+          videoId: videoUUID,
+          tasks: [
+            {
+              name: type,
+              options: {
+                file
+              }
+            }
+          ],
+          expectedStatus
+        })
+      }
+
+      it('Should fail without file', async function () {
+        await addIntroOutro('add-intro', undefined)
+        await addIntroOutro('add-outro', undefined)
+      })
+
+      it('Should fail with an invalid file', async function () {
+        await addIntroOutro('add-intro', 'thumbnail.jpg')
+        await addIntroOutro('add-outro', 'thumbnail.jpg')
+      })
+
+      it('Should fail with a file that does not contain video stream', async function () {
+        await addIntroOutro('add-intro', 'sample.ogg')
+        await addIntroOutro('add-outro', 'sample.ogg')
+
+      })
+
+      it('Should succeed with the correct params', async function () {
+        this.timeout(120000)
+
+        await addIntroOutro('add-intro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
+        await waitJobs([ server ])
+
+        await addIntroOutro('add-outro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
+        await waitJobs([ server ])
+      })
+
+      it('Should check total quota when creating the task', async function () {
+        this.timeout(120000)
+
+        const user = await server.users.create({ username: 'user_quota_1' })
+        const token = await server.login.getAccessToken('user_quota_1')
+        const { uuid } = await server.videos.quickUpload({ token, name: 'video_quota_1', fixture: 'video_short.mp4' })
+
+        const addIntroOutroByUser = (type: 'add-intro' | 'add-outro', expectedStatus: HttpStatusCode) => {
+          return command.createEditionTasks({
+            token,
+            videoId: uuid,
+            tasks: [
+              {
+                name: type,
+                options: {
+                  file: 'video_short.mp4'
+                }
+              }
+            ],
+            expectedStatus
+          })
+        }
+
+        await waitJobs([ server ])
+
+        const { videoQuotaUsed } = await server.users.getMyQuotaUsed({ token })
+        await server.users.update({ userId: user.id, videoQuota: Math.round(videoQuotaUsed * 2.5) })
+
+        // Still valid
+        await addIntroOutroByUser('add-intro', HttpStatusCode.NO_CONTENT_204)
+
+        await waitJobs([ server ])
+
+        // Too much quota
+        await addIntroOutroByUser('add-intro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
+        await addIntroOutroByUser('add-outro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index 3f9355d2de6a3265ae696262e8843fd127408a3f..d756a02c117ce20ab6c7e4f86bd615a1321010e4 100644 (file)
@@ -3,7 +3,7 @@
 import 'mocha'
 import * as chai from 'chai'
 import { basename, join } from 'path'
-import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils'
+import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
 import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
 import { wait } from '@shared/core-utils'
 import {
@@ -562,7 +562,7 @@ describe('Test live', function () {
           const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
 
           const probe = await ffprobePromise(segmentPath)
-          const videoStream = await getVideoStreamFromFile(segmentPath, probe)
+          const videoStream = await getVideoStream(segmentPath, probe)
 
           expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
           expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height])
index 0073c71e10e34782c67dd71df6ff1a7149c154e5..cd4c053d20b7ff366cd170fbf8e7b848e6adb850 100644 (file)
@@ -26,7 +26,7 @@ describe('Test channels search', function () {
 
     const servers = await Promise.all([
       createSingleServer(1),
-      createSingleServer(2, { transcoding: { enabled: false } })
+      createSingleServer(2)
     ])
     server = servers[0]
     remoteServer = servers[1]
@@ -35,6 +35,8 @@ describe('Test channels search', function () {
     await setDefaultChannelAvatar(server)
     await setDefaultAccountAvatar(server)
 
+    await servers[1].config.disableTranscoding()
+
     {
       await server.users.create({ username: 'user1' })
       const channel = {
index fcf2f2ee263628d900620c162d2f0e9c9f9ecff5..d9f12d31673921b03e2f321d182a564da67cc024 100644 (file)
@@ -29,7 +29,7 @@ describe('Test playlists search', function () {
 
     const servers = await Promise.all([
       createSingleServer(1),
-      createSingleServer(2, { transcoding: { enabled: false } })
+      createSingleServer(2)
     ])
     server = servers[0]
     remoteServer = servers[1]
@@ -39,6 +39,8 @@ describe('Test playlists search', function () {
     await setDefaultChannelAvatar([ remoteServer, server ])
     await setDefaultAccountAvatar([ remoteServer, server ])
 
+    await servers[1].config.disableTranscoding()
+
     {
       const videoId = (await server.videos.upload()).uuid
 
index 2356f701c46c8df84816ab81af93ec2e0bb68887..565b2953acf3a057ccd22150fe973a5ebfd99e2e 100644 (file)
@@ -97,6 +97,8 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
   expect(data.live.transcoding.resolutions['1440p']).to.be.false
   expect(data.live.transcoding.resolutions['2160p']).to.be.false
 
+  expect(data.videoEditor.enabled).to.be.false
+
   expect(data.import.videos.concurrency).to.equal(2)
   expect(data.import.videos.http.enabled).to.be.true
   expect(data.import.videos.torrent.enabled).to.be.true
@@ -197,6 +199,8 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.live.transcoding.resolutions['1080p']).to.be.true
   expect(data.live.transcoding.resolutions['2160p']).to.be.true
 
+  expect(data.videoEditor.enabled).to.be.true
+
   expect(data.import.videos.concurrency).to.equal(4)
   expect(data.import.videos.http.enabled).to.be.false
   expect(data.import.videos.torrent.enabled).to.be.false
@@ -341,6 +345,9 @@ const newCustomConfig: CustomConfig = {
       }
     }
   },
+  videoEditor: {
+    enabled: true
+  },
   import: {
     videos: {
       concurrency: 4,
index f0334532bf52033fc15d99c11b8272b6b683547a..2296c0cb978838179a5c20a400b9e3ecf6686373 100644 (file)
@@ -230,13 +230,7 @@ describe('Test stats (excluding redundancy)', function () {
   it('Should have the correct AP stats', async function () {
     this.timeout(60000)
 
-    await servers[0].config.updateCustomSubConfig({
-      newConfig: {
-        transcoding: {
-          enabled: false
-        }
-      }
-    })
+    await servers[0].config.disableTranscoding()
 
     const first = await servers[1].stats.get()
 
index e58360ffe57ef3e79d18d6d11c7807b0dcf317ac..e7e73d382374f37c1c396707416ad0b968e164cb 100644 (file)
@@ -2,7 +2,7 @@
 
 import 'mocha'
 import * as chai from 'chai'
-import { getAudioStream, getVideoStreamSize } from '@server/helpers/ffprobe-utils'
+import { getAudioStream, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
 import {
   cleanupTests,
   createMultipleServers,
@@ -91,9 +91,8 @@ describe('Test audio only video transcoding', function () {
       expect(audioStream['codec_name']).to.be.equal('aac')
       expect(audioStream['bit_rate']).to.be.at.most(384 * 8000)
 
-      const size = await getVideoStreamSize(path)
-      expect(size.height).to.equal(0)
-      expect(size.width).to.equal(0)
+      const size = await getVideoStreamDimensionsInfo(path)
+      expect(size).to.not.exist
     }
   })
 
index bedb9b8b6da4bef4d37d83024509bb151e7a064a..72e6ae2b4b3d42880975d5d03c76b6fbac755199 100644 (file)
@@ -8,6 +8,7 @@ import './video-channels'
 import './video-comments'
 import './video-create-transcoding'
 import './video-description'
+import './video-editor'
 import './video-files'
 import './video-hls'
 import './video-imports'
diff --git a/server/tests/api/videos/video-editor.ts b/server/tests/api/videos/video-editor.ts
new file mode 100644 (file)
index 0000000..a9b6950
--- /dev/null
@@ -0,0 +1,368 @@
+import { expect } from 'chai'
+import { expectStartWith, getAllFiles } from '@server/tests/shared'
+import { areObjectStorageTestsDisabled } from '@shared/core-utils'
+import { VideoEditorTask } from '@shared/models'
+import {
+  cleanupTests,
+  createMultipleServers,
+  doubleFollow,
+  ObjectStorageCommand,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  setDefaultVideoChannel,
+  VideoEditorCommand,
+  waitJobs
+} from '@shared/server-commands'
+
+describe('Test video editor', 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
+
+    await waitJobs(servers)
+  }
+
+  async function createTasks (tasks: VideoEditorTask[]) {
+    await servers[0].videoEditor.createEditionTasks({ videoId: videoUUID, tasks })
+    await waitJobs(servers)
+  }
+
+  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.enableMinimumTranscoding()
+
+    await servers[0].config.updateExistingSubConfig({
+      newConfig: {
+        videoEditor: {
+          enabled: true
+        }
+      }
+    })
+  })
+
+  describe('Cutting', function () {
+
+    it('Should cut the beginning of the video', async function () {
+      this.timeout(120_000)
+
+      await renewVideo()
+      await waitJobs(servers)
+
+      const beforeTasks = new Date()
+
+      await createTasks([
+        {
+          name: 'cut',
+          options: {
+            start: 2
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 3)
+
+        const video = await server.videos.get({ id: videoUUID })
+        expect(new Date(video.publishedAt)).to.be.below(beforeTasks)
+      }
+    })
+
+    it('Should cut the end of the video', async function () {
+      this.timeout(120_000)
+      await renewVideo()
+
+      await createTasks([
+        {
+          name: 'cut',
+          options: {
+            end: 2
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 2)
+      }
+    })
+
+    it('Should cut start/end of the video', async function () {
+      this.timeout(120_000)
+      await renewVideo('video_short1.webm') // 10 seconds video duration
+
+      await createTasks([
+        {
+          name: 'cut',
+          options: {
+            start: 2,
+            end: 6
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 4)
+      }
+    })
+  })
+
+  describe('Intro/Outro', function () {
+
+    it('Should add an intro', async function () {
+      this.timeout(120_000)
+      await renewVideo()
+
+      await createTasks([
+        {
+          name: 'add-intro',
+          options: {
+            file: 'video_short.webm'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 10)
+      }
+    })
+
+    it('Should add an outro', async function () {
+      this.timeout(120_000)
+      await renewVideo()
+
+      await createTasks([
+        {
+          name: 'add-outro',
+          options: {
+            file: 'video_very_short_240p.mp4'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 7)
+      }
+    })
+
+    it('Should add an intro/outro', async function () {
+      this.timeout(120_000)
+      await renewVideo()
+
+      await createTasks([
+        {
+          name: 'add-intro',
+          options: {
+            file: 'video_very_short_240p.mp4'
+          }
+        },
+        {
+          name: 'add-outro',
+          options: {
+            // Different frame rate
+            file: 'video_short2.webm'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 12)
+      }
+    })
+
+    it('Should add an intro to a video without audio', async function () {
+      this.timeout(120_000)
+      await renewVideo('video_short_no_audio.mp4')
+
+      await createTasks([
+        {
+          name: 'add-intro',
+          options: {
+            file: 'video_very_short_240p.mp4'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 7)
+      }
+    })
+
+    it('Should add an outro without audio to a video with audio', async function () {
+      this.timeout(120_000)
+      await renewVideo()
+
+      await createTasks([
+        {
+          name: 'add-outro',
+          options: {
+            file: 'video_short_no_audio.mp4'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 10)
+      }
+    })
+
+    it('Should add an outro without audio to a video with audio', async function () {
+      this.timeout(120_000)
+      await renewVideo('video_short_no_audio.mp4')
+
+      await createTasks([
+        {
+          name: 'add-outro',
+          options: {
+            file: 'video_short_no_audio.mp4'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        await checkDuration(server, 10)
+      }
+    })
+  })
+
+  describe('Watermark', function () {
+
+    it('Should add a watermark to the video', async function () {
+      this.timeout(120_000)
+      await renewVideo()
+
+      const video = await servers[0].videos.get({ id: videoUUID })
+      const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+      await createTasks([
+        {
+          name: 'add-watermark',
+          options: {
+            file: 'thumbnail.png'
+          }
+        }
+      ])
+
+      for (const server of servers) {
+        const video = await server.videos.get({ id: videoUUID })
+        const fileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+        for (const oldUrl of oldFileUrls) {
+          expect(fileUrls).to.not.include(oldUrl)
+        }
+      }
+    })
+  })
+
+  describe('Complex tasks', function () {
+    it('Should run a complex task', async function () {
+      this.timeout(240_000)
+      await renewVideo()
+
+      await createTasks(VideoEditorCommand.getComplexTask())
+
+      for (const server of servers) {
+        await checkDuration(server, 9)
+      }
+    })
+  })
+
+  describe('HLS only video edition', function () {
+
+    before(async function () {
+      // Disable webtorrent
+      await servers[0].config.updateExistingSubConfig({
+        newConfig: {
+          transcoding: {
+            webtorrent: {
+              enabled: false
+            }
+          }
+        }
+      })
+    })
+
+    it('Should run a complex task on HLS only video', async function () {
+      this.timeout(240_000)
+      await renewVideo()
+
+      await createTasks(VideoEditorCommand.getComplexTask())
+
+      for (const server of servers) {
+        const video = await server.videos.get({ id: videoUUID })
+        expect(video.files).to.have.lengthOf(0)
+
+        await checkDuration(server, 9)
+      }
+    })
+  })
+
+  describe('Object storage video edition', function () {
+    if (areObjectStorageTestsDisabled()) return
+
+    before(async function () {
+      await ObjectStorageCommand.prepareDefaultBuckets()
+
+      await servers[0].kill()
+      await servers[0].run(ObjectStorageCommand.getDefaultConfig())
+
+      await servers[0].config.enableMinimumTranscoding()
+    })
+
+    it('Should run a complex task on a video in object storage', async function () {
+      this.timeout(240_000)
+      await renewVideo()
+
+      const video = await servers[0].videos.get({ id: videoUUID })
+      const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+      await createTasks(VideoEditorCommand.getComplexTask())
+
+      for (const server of servers) {
+        const video = await server.videos.get({ id: videoUUID })
+        const files = getAllFiles(video)
+
+        for (const f of files) {
+          expect(oldFileUrls).to.not.include(f.fileUrl)
+        }
+
+        for (const webtorrentFile of video.files) {
+          expectStartWith(webtorrentFile.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl())
+        }
+
+        for (const hlsFile of video.streamingPlaylists[0].files) {
+          expectStartWith(hlsFile.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl())
+        }
+
+        await checkDuration(server, 9)
+      }
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
index 5fdb0fc036e5670560b17bf60ca5a843ac313b0b..3944dc344349ab388dece75ec16761b50511d716 100644 (file)
@@ -45,12 +45,16 @@ describe('Playlist thumbnail', function () {
   before(async function () {
     this.timeout(120000)
 
-    servers = await createMultipleServers(2, { transcoding: { enabled: false } })
+    servers = await createMultipleServers(2)
 
     // Get the access tokens
     await setAccessTokensToServers(servers)
     await setDefaultVideoChannel(servers)
 
+    for (const server of servers) {
+      await server.config.disableTranscoding()
+    }
+
     // Server 1 and server 2 follow each other
     await doubleFollow(servers[0], servers[1])
 
index 1e8dbef0247b3497e4606b65d5fcf6bc08309c66..c33a63df08aa0bc8655537121f20df66e1c46f26 100644 (file)
@@ -75,13 +75,17 @@ describe('Test video playlists', function () {
   before(async function () {
     this.timeout(120000)
 
-    servers = await createMultipleServers(3, { transcoding: { enabled: false } })
+    servers = await createMultipleServers(3)
 
     // Get the access tokens
     await setAccessTokensToServers(servers)
     await setDefaultVideoChannel(servers)
     await setDefaultAccountAvatar(servers)
 
+    for (const server of servers) {
+      await server.config.disableTranscoding()
+    }
+
     // Server 1 and server 2 follow each other
     await doubleFollow(servers[0], servers[1])
     // Server 1 and server 3 follow each other
index d24a8f4e152273b55b1fe94893d03117a9d76d87..245c4c012dbf010aabdec0edb2e40c0cec0f174b 100644 (file)
@@ -3,10 +3,17 @@
 import 'mocha'
 import * as chai from 'chai'
 import { omit } from 'lodash'
-import { canDoQuickTranscode } from '@server/helpers/ffprobe-utils'
-import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared'
+import { canDoQuickTranscode } from '@server/helpers/ffmpeg'
+import { generateHighBitrateVideo, generateVideoWithFramerate, getAllFiles } from '@server/tests/shared'
 import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils'
-import { getAudioStream, getMetadataFromFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils'
+import {
+  getAudioStream,
+  buildFileMetadata,
+  getVideoStreamBitrate,
+  getVideoStreamFPS,
+  getVideoStreamDimensionsInfo,
+  hasAudioStream
+} from '@shared/extra-utils'
 import { HttpStatusCode, VideoState } from '@shared/models'
 import {
   cleanupTests,
@@ -287,8 +294,7 @@ describe('Test video transcoding', function () {
         const file = videoDetails.files.find(f => f.resolution.id === 240)
         const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
 
-        const probe = await getAudioStream(path)
-        expect(probe).to.not.have.property('audioStream')
+        expect(await hasAudioStream(path)).to.be.false
       }
     })
 
@@ -478,14 +484,14 @@ describe('Test video transcoding', function () {
         for (const resolution of [ 144, 240, 360, 480 ]) {
           const file = videoDetails.files.find(f => f.resolution.id === resolution)
           const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
-          const fps = await getVideoFileFPS(path)
+          const fps = await getVideoStreamFPS(path)
 
           expect(fps).to.be.below(31)
         }
 
         const file = videoDetails.files.find(f => f.resolution.id === 720)
         const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
-        const fps = await getVideoFileFPS(path)
+        const fps = await getVideoStreamFPS(path)
 
         expect(fps).to.be.above(58).and.below(62)
       }
@@ -499,7 +505,7 @@ describe('Test video transcoding', function () {
       {
         tempFixturePath = await generateVideoWithFramerate(59)
 
-        const fps = await getVideoFileFPS(tempFixturePath)
+        const fps = await getVideoStreamFPS(tempFixturePath)
         expect(fps).to.be.equal(59)
       }
 
@@ -522,14 +528,14 @@ describe('Test video transcoding', function () {
         {
           const file = video.files.find(f => f.resolution.id === 240)
           const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
-          const fps = await getVideoFileFPS(path)
+          const fps = await getVideoStreamFPS(path)
           expect(fps).to.be.equal(25)
         }
 
         {
           const file = video.files.find(f => f.resolution.id === 720)
           const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
-          const fps = await getVideoFileFPS(path)
+          const fps = await getVideoStreamFPS(path)
           expect(fps).to.be.equal(59)
         }
       }
@@ -563,9 +569,9 @@ describe('Test video transcoding', function () {
           const file = video.files.find(f => f.resolution.id === resolution)
           const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
 
-          const bitrate = await getVideoFileBitrate(path)
-          const fps = await getVideoFileFPS(path)
-          const dataResolution = await getVideoFileResolution(path)
+          const bitrate = await getVideoStreamBitrate(path)
+          const fps = await getVideoStreamFPS(path)
+          const dataResolution = await getVideoStreamDimensionsInfo(path)
 
           expect(resolution).to.equal(resolution)
 
@@ -613,7 +619,7 @@ describe('Test video transcoding', function () {
         const file = video.files.find(f => f.resolution.id === r)
 
         const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
-        const bitrate = await getVideoFileBitrate(path)
+        const bitrate = await getVideoStreamBitrate(path)
 
         const inputBitrate = 60_000
         const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r })
@@ -637,7 +643,7 @@ describe('Test video transcoding', function () {
         const video = await servers[1].videos.get({ id: videoUUID })
         const file = video.files.find(f => f.resolution.id === 240)
         const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
-        const metadata = await getMetadataFromFile(path)
+        const metadata = await buildFileMetadata(path)
 
         // expected format properties
         for (const p of [
@@ -668,8 +674,7 @@ describe('Test video transcoding', function () {
       for (const server of servers) {
         const videoDetails = await server.videos.get({ id: videoUUID })
 
-        const videoFiles = videoDetails.files
-                                      .concat(videoDetails.streamingPlaylists[0].files)
+        const videoFiles = getAllFiles(videoDetails)
         expect(videoFiles).to.have.lengthOf(10)
 
         for (const file of videoFiles) {
index da89ff153f68f2d2b7fa0c35d835d2ff45baad95..7c49efd20767497550b9cf6ff5496d3fddbd5db5 100644 (file)
@@ -12,6 +12,7 @@ import {
   setAccessTokensToServers,
   waitJobs
 } from '@shared/server-commands'
+import { getAllFiles } from '../shared'
 
 describe('Test update host scripts', function () {
   let server: PeerTubeServer
@@ -108,7 +109,7 @@ describe('Test update host scripts', function () {
 
     for (const video of data) {
       const videoDetails = await server.videos.get({ id: video.id })
-      const files = videoDetails.files.concat(videoDetails.streamingPlaylists[0].files)
+      const files = getAllFiles(videoDetails)
 
       expect(files).to.have.lengthOf(8)
 
index 52ba396e559e9a255c500d7914fda3c99a1b7017..7adfc1277cf47e8ea743801ea3fc89bfb2d8a56b 100644 (file)
@@ -410,13 +410,7 @@ describe('Test plugin filter hooks', function () {
     before(async function () {
       this.timeout(60000)
 
-      await servers[0].config.updateCustomSubConfig({
-        newConfig: {
-          transcoding: {
-            enabled: false
-          }
-        }
-      })
+      await servers[0].config.disableTranscoding()
 
       for (const name of [ 'bad embed', 'good embed' ]) {
         {
index 5ab6864729327883a6081346eb9f4973daf0a7fc..49569f1fa412eb08b1e90f899df4cd002a1463b1 100644 (file)
@@ -2,7 +2,8 @@
 
 import 'mocha'
 import { expect } from 'chai'
-import { getAudioStream, getVideoFileFPS, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils'
+import { getAudioStream, getVideoStreamFPS, getVideoStream } from '@server/helpers/ffmpeg'
+import { VideoPrivacy } from '@shared/models'
 import {
   cleanupTests,
   createSingleServer,
@@ -13,7 +14,6 @@ import {
   testFfmpegStreamError,
   waitJobs
 } from '@shared/server-commands'
-import { VideoPrivacy } from '@shared/models'
 
 async function createLiveWrapper (server: PeerTubeServer) {
   const liveAttributes = {
@@ -92,7 +92,7 @@ describe('Test transcoding plugins', function () {
 
     async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) {
       const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8`
-      const videoFPS = await getVideoFileFPS(playlistUrl)
+      const videoFPS = await getVideoStreamFPS(playlistUrl)
 
       if (type === 'above') {
         expect(videoFPS).to.be.above(fps)
@@ -252,7 +252,7 @@ describe('Test transcoding plugins', function () {
       const audioProbe = await getAudioStream(path)
       expect(audioProbe.audioStream.codec_name).to.equal('opus')
 
-      const videoProbe = await getVideoStreamFromFile(path)
+      const videoProbe = await getVideoStream(path)
       expect(videoProbe.codec_name).to.equal('vp9')
     })
 
@@ -269,7 +269,7 @@ describe('Test transcoding plugins', function () {
       const audioProbe = await getAudioStream(playlistUrl)
       expect(audioProbe.audioStream.codec_name).to.equal('opus')
 
-      const videoProbe = await getVideoStreamFromFile(playlistUrl)
+      const videoProbe = await getVideoStream(playlistUrl)
       expect(videoProbe.codec_name).to.equal('h264')
     })
   })
index f806df2f58705cf721f319657a6e9cb6a97b7f1a..9a57084e45dc408aaced4eff61a56650bc17106c 100644 (file)
@@ -3,12 +3,12 @@ import ffmpeg from 'fluent-ffmpeg'
 import { ensureDir, pathExists } from 'fs-extra'
 import { dirname } from 'path'
 import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils'
-import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils'
+import { getVideoStreamBitrate, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '@shared/extra-utils'
 
 async function ensureHasTooBigBitrate (fixturePath: string) {
-  const bitrate = await getVideoFileBitrate(fixturePath)
-  const dataResolution = await getVideoFileResolution(fixturePath)
-  const fps = await getVideoFileFPS(fixturePath)
+  const bitrate = await getVideoStreamBitrate(fixturePath)
+  const dataResolution = await getVideoStreamDimensionsInfo(fixturePath)
+  const fps = await getVideoStreamFPS(fixturePath)
 
   const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
   expect(bitrate).to.be.above(maxBitrate)
index 6be094f2bfcea4f21311f31976da7382ef9874dc..989865a4906f74887f2b411240e1caeeac9205c9 100644 (file)
@@ -240,6 +240,16 @@ async function uploadRandomVideoOnServers (
   return res
 }
 
+function getAllFiles (video: VideoDetails) {
+  const files = video.files
+
+  if (video.streamingPlaylists[0]) {
+    return files.concat(video.streamingPlaylists[0].files)
+  }
+
+  return files
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -247,5 +257,6 @@ export {
   checkUploadVideoParam,
   uploadRandomVideoOnServers,
   checkVideoFilesWereRemoved,
-  saveVideoInServers
+  saveVideoInServers,
+  getAllFiles
 }
index 1a99b598aabacb6be95983d07e610c81ff5a726c..91a8cf3d88e99ad9e8835c4fde6691620c80b00d 100644 (file)
@@ -40,7 +40,7 @@ import {
   MVideoRedundancyVideo,
   MVideoShareActor,
   MVideoThumbnail
-} from '../../types/models'
+} from './models'
 import { Writable } from 'stream'
 
 declare module 'express' {
@@ -60,6 +60,7 @@ declare module 'express' {
   export type UploadFileForCheck = {
     originalname: string
     mimetype: string
+    size: number
   }
 
   export type UploadFilesForCheck = {
index 53a3aa0010758bd47f974e79941855668514355e..dfacd251c6f84df5e60c87297603ae56bee06354 100644 (file)
@@ -17,12 +17,22 @@ function ffprobePromise (path: string) {
   })
 }
 
+// ---------------------------------------------------------------------------
+// Audio
+// ---------------------------------------------------------------------------
+
 async function isAudioFile (path: string, existingProbe?: FfprobeData) {
-  const videoStream = await getVideoStreamFromFile(path, existingProbe)
+  const videoStream = await getVideoStream(path, existingProbe)
 
   return !videoStream
 }
 
+async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
+  const { audioStream } = await getAudioStream(path, existingProbe)
+
+  return !!audioStream
+}
+
 async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
   // without position, ffprobe considers the last input only
   // we make it consider the first input only
@@ -78,29 +88,26 @@ function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) {
   }
 }
 
-async function getVideoStreamSize (path: string, existingProbe?: FfprobeData): Promise<{ width: number, height: number }> {
-  const videoStream = await getVideoStreamFromFile(path, existingProbe)
-
-  return videoStream === null
-    ? { width: 0, height: 0 }
-    : { width: videoStream.width, height: videoStream.height }
-}
+// ---------------------------------------------------------------------------
+// Video
+// ---------------------------------------------------------------------------
 
-async function getVideoFileResolution (path: string, existingProbe?: FfprobeData) {
-  const size = await getVideoStreamSize(path, existingProbe)
+async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
+  const videoStream = await getVideoStream(path, existingProbe)
+  if (!videoStream) return undefined
 
   return {
-    width: size.width,
-    height: size.height,
-    ratio: Math.max(size.height, size.width) / Math.min(size.height, size.width),
-    resolution: Math.min(size.height, size.width),
-    isPortraitMode: size.height > size.width
+    width: videoStream.width,
+    height: videoStream.height,
+    ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width),
+    resolution: Math.min(videoStream.height, videoStream.width),
+    isPortraitMode: videoStream.height > videoStream.width
   }
 }
 
-async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) {
-  const videoStream = await getVideoStreamFromFile(path, existingProbe)
-  if (videoStream === null) return 0
+async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
+  const videoStream = await getVideoStream(path, existingProbe)
+  if (!videoStream) return 0
 
   for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
     const valuesText: string = videoStream[key]
@@ -116,19 +123,19 @@ async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) {
   return 0
 }
 
-async function getMetadataFromFile (path: string, existingProbe?: FfprobeData) {
+async function buildFileMetadata (path: string, existingProbe?: FfprobeData) {
   const metadata = existingProbe || await ffprobePromise(path)
 
   return new VideoFileMetadata(metadata)
 }
 
-async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
-  const metadata = await getMetadataFromFile(path, existingProbe)
+async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
+  const metadata = await buildFileMetadata(path, existingProbe)
 
   let bitrate = metadata.format.bit_rate as number
   if (bitrate && !isNaN(bitrate)) return bitrate
 
-  const videoStream = await getVideoStreamFromFile(path, existingProbe)
+  const videoStream = await getVideoStream(path, existingProbe)
   if (!videoStream) return undefined
 
   bitrate = videoStream?.bit_rate
@@ -137,51 +144,30 @@ async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData):
   return undefined
 }
 
-async function getDurationFromVideoFile (path: string, existingProbe?: FfprobeData) {
-  const metadata = await getMetadataFromFile(path, existingProbe)
+async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
+  const metadata = await buildFileMetadata(path, existingProbe)
 
   return Math.round(metadata.format.duration)
 }
 
-async function getVideoStreamFromFile (path: string, existingProbe?: FfprobeData) {
-  const metadata = await getMetadataFromFile(path, existingProbe)
-
-  return metadata.streams.find(s => s.codec_type === 'video') || null
-}
-
-async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
-  const parsedAudio = await getAudioStream(path, probe)
-
-  if (!parsedAudio.audioStream) return true
-
-  if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
-
-  const audioBitrate = parsedAudio.bitrate
-  if (!audioBitrate) return false
-
-  const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
-  if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
-
-  const channelLayout = parsedAudio.audioStream['channel_layout']
-  // Causes playback issues with Chrome
-  if (!channelLayout || channelLayout === 'unknown') return false
+async function getVideoStream (path: string, existingProbe?: FfprobeData) {
+  const metadata = await buildFileMetadata(path, existingProbe)
 
-  return true
+  return metadata.streams.find(s => s.codec_type === 'video')
 }
 
 // ---------------------------------------------------------------------------
 
 export {
-  getVideoStreamSize,
-  getVideoFileResolution,
-  getMetadataFromFile,
+  getVideoStreamDimensionsInfo,
+  buildFileMetadata,
   getMaxAudioBitrate,
-  getVideoStreamFromFile,
-  getDurationFromVideoFile,
+  getVideoStream,
+  getVideoStreamDuration,
   getAudioStream,
-  getVideoFileFPS,
+  getVideoStreamFPS,
   isAudioFile,
   ffprobePromise,
-  getVideoFileBitrate,
-  canDoQuickAudioTranscode
+  getVideoStreamBitrate,
+  hasAudioStream
 }
index 52d3d958884cc2a8a516826f966e983b876f4ac5..c9e7654de4053b501c2b29e8f38a1da3582b6337 100644 (file)
@@ -143,6 +143,10 @@ export interface CustomConfig {
     }
   }
 
+  videoEditor: {
+    enabled: boolean
+  }
+
   import: {
     videos: {
       concurrency: number
index 1519d1c3e4bab01f3fdfa5124604bde145208140..d0293f542fcc7b7406bb1ddf31b1857e2433bcdd 100644 (file)
@@ -1,4 +1,5 @@
 import { ContextType } from '../activitypub/context'
+import { VideoEditorTaskCut } from '../videos/editor'
 import { VideoResolution } from '../videos/file/video-resolution.enum'
 import { SendEmailOptions } from './emailer.model'
 
@@ -20,6 +21,7 @@ export type JobType =
   | 'video-live-ending'
   | 'actor-keys'
   | 'move-to-object-storage'
+  | 'video-edition'
 
 export interface Job {
   id: number
@@ -155,3 +157,40 @@ export interface MoveObjectStoragePayload {
   videoUUID: string
   isNewVideo: boolean
 }
+
+export type VideoEditorTaskCutPayload = VideoEditorTaskCut
+
+export type VideoEditorTaskIntroPayload = {
+  name: 'add-intro'
+
+  options: {
+    file: string
+  }
+}
+
+export type VideoEditorTaskOutroPayload = {
+  name: 'add-outro'
+
+  options: {
+    file: string
+  }
+}
+
+export type VideoEditorTaskWatermarkPayload = {
+  name: 'add-watermark'
+
+  options: {
+    file: string
+  }
+}
+
+export type VideoEditionTaskPayload =
+  VideoEditorTaskCutPayload |
+  VideoEditorTaskIntroPayload |
+  VideoEditorTaskOutroPayload |
+  VideoEditorTaskWatermarkPayload
+
+export interface VideoEditionPayload {
+  videoUUID: string
+  tasks: VideoEditionTaskPayload[]
+}
index 32be96b9da6b7f234e961ee73bf24440d967930f..0fe8b0de8fd0fa3bd9afacd9560d4d5c21cf4cf0 100644 (file)
@@ -167,6 +167,10 @@ export interface ServerConfig {
     }
   }
 
+  videoEditor: {
+    enabled: boolean
+  }
+
   import: {
     videos: {
       http: {
diff --git a/shared/models/videos/editor/index.ts b/shared/models/videos/editor/index.ts
new file mode 100644 (file)
index 0000000..3436f2c
--- /dev/null
@@ -0,0 +1 @@
+export * from './video-editor-create-edit.model'
diff --git a/shared/models/videos/editor/video-editor-create-edit.model.ts b/shared/models/videos/editor/video-editor-create-edit.model.ts
new file mode 100644 (file)
index 0000000..36b7c8d
--- /dev/null
@@ -0,0 +1,42 @@
+export interface VideoEditorCreateEdition {
+  tasks: VideoEditorTask[]
+}
+
+export type VideoEditorTask =
+  VideoEditorTaskCut |
+  VideoEditorTaskIntro |
+  VideoEditorTaskOutro |
+  VideoEditorTaskWatermark
+
+export interface VideoEditorTaskCut {
+  name: 'cut'
+
+  options: {
+    start?: number
+    end?: number
+  }
+}
+
+export interface VideoEditorTaskIntro {
+  name: 'add-intro'
+
+  options: {
+    file: Blob | string
+  }
+}
+
+export interface VideoEditorTaskOutro {
+  name: 'add-outro'
+
+  options: {
+    file: Blob | string
+  }
+}
+
+export interface VideoEditorTaskWatermark {
+  name: 'add-watermark'
+
+  options: {
+    file: Blob | string
+  }
+}
index 67614efc916fe6d54320b12a4804eeefb97febb6..e8eb227ab77cfa6a618c0869248f0fdcb4b1de61 100644 (file)
@@ -3,6 +3,7 @@ export * from './caption'
 export * from './change-ownership'
 export * from './channel'
 export * from './comment'
+export * from './editor'
 export * from './live'
 export * from './file'
 export * from './import'
index 25fc1c2da7e54af96f8f50afec610797556ea6c5..9a330ac94b84c01401309c4af433a2490712d2f3 100644 (file)
@@ -2,6 +2,7 @@ export type VideoTranscodingFPS = {
   MIN: number
   STANDARD: number[]
   HD_STANDARD: number[]
+  AUDIO_MERGE: number
   AVERAGE: number
   MAX: number
   KEEP_ORIGIN_FPS_RESOLUTION_MIN: number
index 3a7fb6472c819113e3cd61ebd99b3e291e81cb35..91eacf8dcb3f58d7449374c60e3bec00b2c37feb 100644 (file)
@@ -7,8 +7,11 @@ export type EncoderOptionsBuilderParams = {
 
   resolution: VideoResolution
 
-  // Could be null for "merge audio" transcoding
-  fps?: number
+  // If PeerTube applies a filter, transcoding profile must not copy input stream
+  canCopyAudio: boolean
+  canCopyVideo: boolean
+
+  fps: number
 
   // Could be undefined if we could not get input bitrate (some RTMP streams for example)
   inputBitrate: number
index 09268d2ff13c005765480ae98282eb31844d4090..e45e4adc26c4c569b5bb96be7bac171bf2987ae4 100644 (file)
@@ -6,5 +6,6 @@ export const enum VideoState {
   LIVE_ENDED = 5,
   TO_MOVE_TO_EXTERNAL_STORAGE = 6,
   TRANSCODING_FAILED = 7,
-  TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8
+  TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8,
+  TO_EDIT = 9
 }
index 797231b1d2edadf2fe785daf46a60ea590edae5d..c0042060b5006f6e915b93e4bda88bc088773d9d 100644 (file)
@@ -59,6 +59,9 @@ export class ConfigCommand extends AbstractCommand {
       newConfig: {
         transcoding: {
           enabled: false
+        },
+        videoEditor: {
+          enabled: false
         }
       }
     })
@@ -69,6 +72,10 @@ export class ConfigCommand extends AbstractCommand {
       newConfig: {
         transcoding: {
           enabled: true,
+
+          allowAudioFiles: true,
+          allowAdditionalExtensions: true,
+
           resolutions: ConfigCommand.getCustomConfigResolutions(true),
 
           webtorrent: {
@@ -82,6 +89,28 @@ export class ConfigCommand extends AbstractCommand {
     })
   }
 
+  enableMinimumTranscoding (webtorrent = true, hls = true) {
+    return this.updateExistingSubConfig({
+      newConfig: {
+        transcoding: {
+          enabled: true,
+          resolutions: {
+            ...ConfigCommand.getCustomConfigResolutions(false),
+
+            '240p': true
+          },
+
+          webtorrent: {
+            enabled: webtorrent
+          },
+          hls: {
+            enabled: hls
+          }
+        }
+      }
+    })
+  }
+
   getConfig (options: OverrideCommandOptions = {}) {
     const path = '/api/v1/config'
 
@@ -148,7 +177,7 @@ export class ConfigCommand extends AbstractCommand {
   async updateExistingSubConfig (options: OverrideCommandOptions & {
     newConfig: DeepPartial<CustomConfig>
   }) {
-    const existing = await this.getCustomConfig(options)
+    const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 })
 
     return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) })
   }
@@ -282,6 +311,9 @@ export class ConfigCommand extends AbstractCommand {
           }
         }
       },
+      videoEditor: {
+        enabled: false
+      },
       import: {
         videos: {
           concurrency: 3,
index da89fd876b6c46628f87289b7ab6a9d62c3824a7..af4423e8d25d413f44e6dc0a29d21463208a5cfa 100644 (file)
@@ -25,6 +25,7 @@ import {
   PlaylistsCommand,
   ServicesCommand,
   StreamingPlaylistsCommand,
+  VideoEditorCommand,
   VideosCommand
 } from '../videos'
 import { CommentsCommand } from '../videos/comments-command'
@@ -124,6 +125,7 @@ export class PeerTubeServer {
   login?: LoginCommand
   users?: UsersCommand
   objectStorage?: ObjectStorageCommand
+  videoEditor?: VideoEditorCommand
   videos?: VideosCommand
 
   constructor (options: { serverNumber: number } | { url: string }) {
@@ -394,5 +396,6 @@ export class PeerTubeServer {
     this.users = new UsersCommand(this)
     this.videos = new VideosCommand(this)
     this.objectStorage = new ObjectStorageCommand(this)
+    this.videoEditor = new VideoEditorCommand(this)
   }
 }
index 68a188b2116277329c1e020c12d2c6404acc2394..154aed9a6e1c0021d1e3c7a37bbc1777c292b85c 100644 (file)
@@ -12,4 +12,5 @@ export * from './playlists-command'
 export * from './services-command'
 export * from './streaming-playlists-command'
 export * from './comments-command'
+export * from './video-editor-command'
 export * from './videos-command'
diff --git a/shared/server-commands/videos/video-editor-command.ts b/shared/server-commands/videos/video-editor-command.ts
new file mode 100644 (file)
index 0000000..485edce
--- /dev/null
@@ -0,0 +1,67 @@
+import { HttpStatusCode, VideoEditorTask } from '@shared/models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class VideoEditorCommand extends AbstractCommand {
+
+  static getComplexTask (): VideoEditorTask[] {
+    return [
+      // Total duration: 2
+      {
+        name: 'cut',
+        options: {
+          start: 1,
+          end: 3
+        }
+      },
+
+      // Total duration: 7
+      {
+        name: 'add-outro',
+        options: {
+          file: 'video_short.webm'
+        }
+      },
+
+      {
+        name: 'add-watermark',
+        options: {
+          file: 'thumbnail.png'
+        }
+      },
+
+      // Total duration: 9
+      {
+        name: 'add-intro',
+        options: {
+          file: 'video_very_short_240p.mp4'
+        }
+      }
+    ]
+  }
+
+  createEditionTasks (options: OverrideCommandOptions & {
+    videoId: number | string
+    tasks: VideoEditorTask[]
+  }) {
+    const path = '/api/v1/videos/' + options.videoId + '/editor/edit'
+    const attaches: { [id: string]: any } = {}
+
+    for (let i = 0; i < options.tasks.length; i++) {
+      const task = options.tasks[i]
+
+      if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') {
+        attaches[`tasks[${i}][options][file]`] = task.options.file
+      }
+    }
+
+    return this.postUploadRequest({
+      ...options,
+
+      path,
+      attaches,
+      fields: { tasks: options.tasks },
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+}