From c729caf6cc34630877a0e5a1bda1719384cd0c8a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 11 Feb 2022 10:51:33 +0100 Subject: [PATCH] Add basic video editor support --- .../edit-custom-config.component.ts | 3 + .../edit-vod-transcoding.component.html | 25 + .../edit-vod-transcoding.component.ts | 9 + .../overview/videos/video-list.component.scss | 1 + .../my-videos/my-videos.component.ts | 8 +- client/src/app/+video-editor/edit/index.ts | 2 + .../edit/video-editor-edit.component.html | 88 ++ .../edit/video-editor-edit.component.scss | 76 ++ .../edit/video-editor-edit.component.ts | 202 +++++ .../edit/video-editor-edit.resolver.ts | 18 + client/src/app/+video-editor/index.ts | 1 + client/src/app/+video-editor/shared/index.ts | 1 + .../shared/video-editor.service.ts | 28 + .../video-editor-routing.module.ts | 30 + .../app/+video-editor/video-editor.module.ts | 27 + .../action-buttons.component.ts | 1 + .../information/video-alert.component.html | 4 + .../information/video-alert.component.ts | 4 + client/src/app/app-routing.module.ts | 6 + .../app/shared/shared-forms/form-reactive.ts | 2 +- .../shared-forms/form-validator.service.ts | 2 +- .../timestamp-input.component.html | 3 +- .../timestamp-input.component.scss | 14 +- .../shared-forms/timestamp-input.component.ts | 2 + .../video-actions-dropdown.component.ts | 23 +- .../video-miniature.component.ts | 4 + config/default.yaml | 4 + config/production.yaml.example | 4 + config/test-1.yaml | 3 + config/test-3.yaml | 3 + config/test-4.yaml | 3 + config/test-5.yaml | 3 + config/test-6.yaml | 3 + config/test.yaml | 3 + scripts/create-transcoding-job.ts | 2 +- scripts/print-transcode-command.ts | 8 +- server.ts | 5 +- server/controllers/api/config.ts | 3 + server/controllers/api/videos/editor.ts | 120 +++ server/controllers/api/videos/index.ts | 2 + server/controllers/api/videos/transcoding.ts | 4 +- server/controllers/api/videos/upload.ts | 8 +- .../helpers/custom-validators/actor-images.ts | 11 +- server/helpers/custom-validators/misc.ts | 79 +- .../custom-validators/video-captions.ts | 12 +- .../helpers/custom-validators/video-editor.ts | 52 ++ .../custom-validators/video-imports.ts | 11 +- server/helpers/custom-validators/videos.ts | 27 +- server/helpers/express-utils.ts | 77 +- server/helpers/ffmpeg-utils.ts | 781 ------------------ server/helpers/ffmpeg/ffmpeg-commons.ts | 114 +++ server/helpers/ffmpeg/ffmpeg-edition.ts | 242 ++++++ server/helpers/ffmpeg/ffmpeg-encoders.ts | 116 +++ server/helpers/ffmpeg/ffmpeg-images.ts | 46 ++ server/helpers/ffmpeg/ffmpeg-live.ts | 161 ++++ server/helpers/ffmpeg/ffmpeg-presets.ts | 156 ++++ server/helpers/ffmpeg/ffmpeg-vod.ts | 254 ++++++ server/helpers/{ => ffmpeg}/ffprobe-utils.ts | 97 ++- server/helpers/ffmpeg/index.ts | 8 + server/helpers/image-utils.ts | 28 +- server/helpers/webtorrent.ts | 39 +- server/initializers/checker-after-init.ts | 188 +++-- server/initializers/checker-before-init.ts | 2 +- server/initializers/config.ts | 3 + server/initializers/constants.ts | 22 +- .../migrations/0075-video-resolutions.ts | 8 +- server/lib/hls.ts | 6 +- .../lib/job-queue/handlers/video-edition.ts | 229 +++++ .../job-queue/handlers/video-file-import.ts | 12 +- server/lib/job-queue/handlers/video-import.ts | 8 +- .../job-queue/handlers/video-live-ending.ts | 8 +- .../job-queue/handlers/video-transcoding.ts | 10 +- server/lib/job-queue/job-queue.ts | 9 +- server/lib/live/live-manager.ts | 14 +- server/lib/live/shared/muxing-session.ts | 4 +- server/lib/plugins/plugin-helpers-builder.ts | 2 +- server/lib/plugins/register-helpers.ts | 2 +- server/lib/server-config-manager.ts | 5 +- server/lib/thumbnail.ts | 5 +- ...les.ts => default-transcoding-profiles.ts} | 25 +- .../{video-transcoding.ts => transcoding.ts} | 35 +- server/lib/user.ts | 6 + server/lib/video-editor.ts | 32 + server/lib/video.ts | 6 +- server/middlewares/validators/config.ts | 14 + server/middlewares/validators/shared/utils.ts | 1 + .../middlewares/validators/shared/videos.ts | 26 +- server/middlewares/validators/videos/index.ts | 1 + .../validators/videos/video-captions.ts | 10 +- .../validators/videos/video-comments.ts | 14 +- .../validators/videos/video-editor.ts | 112 +++ .../videos/video-ownership-changes.ts | 21 +- .../validators/videos/video-playlists.ts | 4 +- .../middlewares/validators/videos/videos.ts | 40 +- server/models/video/video.ts | 6 +- server/tests/api/activitypub/refresher.ts | 6 +- server/tests/api/check-params/config.ts | 3 + server/tests/api/check-params/index.ts | 1 + server/tests/api/check-params/video-editor.ts | 385 +++++++++ server/tests/api/live/live.ts | 4 +- server/tests/api/search/search-channels.ts | 4 +- server/tests/api/search/search-playlists.ts | 4 +- server/tests/api/server/config.ts | 7 + server/tests/api/server/stats.ts | 8 +- server/tests/api/videos/audio-only.ts | 7 +- server/tests/api/videos/index.ts | 1 + server/tests/api/videos/video-editor.ts | 368 +++++++++ .../api/videos/video-playlist-thumbnails.ts | 6 +- server/tests/api/videos/video-playlists.ts | 6 +- server/tests/api/videos/video-transcoder.ts | 39 +- server/tests/cli/update-host.ts | 3 +- server/tests/plugins/filter-hooks.ts | 8 +- server/tests/plugins/plugin-transcoding.ts | 10 +- server/tests/shared/generate.ts | 8 +- server/tests/shared/videos.ts | 13 +- server/types/express.d.ts | 3 +- shared/extra-utils/ffprobe.ts | 96 +-- shared/models/server/custom-config.model.ts | 4 + shared/models/server/job.model.ts | 39 + shared/models/server/server-config.model.ts | 4 + shared/models/videos/editor/index.ts | 1 + .../editor/video-editor-create-edit.model.ts | 42 + shared/models/videos/index.ts | 1 + .../video-transcoding-fps.model.ts | 1 + .../transcoding/video-transcoding.model.ts | 7 +- shared/models/videos/video-state.enum.ts | 3 +- .../server-commands/server/config-command.ts | 34 +- shared/server-commands/server/server.ts | 3 + shared/server-commands/videos/index.ts | 1 + .../videos/video-editor-command.ts | 67 ++ 130 files changed, 3888 insertions(+), 1272 deletions(-) create mode 100644 client/src/app/+video-editor/edit/index.ts create mode 100644 client/src/app/+video-editor/edit/video-editor-edit.component.html create mode 100644 client/src/app/+video-editor/edit/video-editor-edit.component.scss create mode 100644 client/src/app/+video-editor/edit/video-editor-edit.component.ts create mode 100644 client/src/app/+video-editor/edit/video-editor-edit.resolver.ts create mode 100644 client/src/app/+video-editor/index.ts create mode 100644 client/src/app/+video-editor/shared/index.ts create mode 100644 client/src/app/+video-editor/shared/video-editor.service.ts create mode 100644 client/src/app/+video-editor/video-editor-routing.module.ts create mode 100644 client/src/app/+video-editor/video-editor.module.ts create mode 100644 server/controllers/api/videos/editor.ts create mode 100644 server/helpers/custom-validators/video-editor.ts delete mode 100644 server/helpers/ffmpeg-utils.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-commons.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-edition.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-encoders.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-images.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-live.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-presets.ts create mode 100644 server/helpers/ffmpeg/ffmpeg-vod.ts rename server/helpers/{ => ffmpeg}/ffprobe-utils.ts (67%) create mode 100644 server/helpers/ffmpeg/index.ts create mode 100644 server/lib/job-queue/handlers/video-edition.ts rename server/lib/transcoding/{video-transcoding-profiles.ts => default-transcoding-profiles.ts} (91%) rename server/lib/transcoding/{video-transcoding.ts => transcoding.ts} (92%) create mode 100644 server/lib/video-editor.ts create mode 100644 server/middlewares/validators/videos/video-editor.ts create mode 100644 server/tests/api/check-params/video-editor.ts create mode 100644 server/tests/api/videos/video-editor.ts create mode 100644 shared/models/videos/editor/index.ts create mode 100644 shared/models/videos/editor/video-editor-create-edit.model.ts create mode 100644 shared/server-commands/videos/video-editor-command.ts diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index f2eaa3033..e3b6f8305 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { resolutions: {} } }, + videoEditor: { + enabled: null + }, autoBlacklist: { videos: { ofUsers: { diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html index 1158f027b..2be855756 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html @@ -192,4 +192,29 @@ + +
+
+
VIDEO EDITOR
+
+ Allows your users to edit their video (cut, add intro/outro, add a watermark etc) +
+
+ +
+ + +
+ + + ⚠️ You need to enable transcoding first to enable video editor + + +
+
+
+
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts index 3397c3dbd..948c10b69 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts @@ -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) + } + }) } } diff --git a/client/src/app/+admin/overview/videos/video-list.component.scss b/client/src/app/+admin/overview/videos/video-list.component.scss index 543cb433c..616b9bc6b 100644 --- a/client/src/app/+admin/overview/videos/video-list.component.scss +++ b/client/src/app/+admin/overview/videos/video-list.component.scss @@ -1,5 +1,6 @@ @use '_variables' as *; @use '_mixins' as *; + my-embed { display: block; max-width: 500px; diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts index 261e87f99..c998b7c49 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.ts +++ b/client/src/app/+my-library/my-videos/my-videos.component.ts @@ -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 index 000000000..390ca80fc --- /dev/null +++ b/client/src/app/+video-editor/edit/index.ts @@ -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 index 000000000..d33dfaf18 --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.component.html @@ -0,0 +1,88 @@ +
+

Edit {{ video.name }}

+ +
+
+ +
+

CUT VIDEO

+ +
Set a new start/end.
+ +
+ + +
+ +
+ + +
+
+ +
+

ADD INTRO

+ +
Concatenate a file at the beginning of the video.
+ +
+ +
+
+ +
+

ADD OUTRO

+ +
Concatenate a file at the end of the video.
+ +
+ +
+
+ +
+

ADD WATERMARK

+ +
Add a watermark image to the video.
+ +
+ +
+
+ + +
+ + +
+
+ + +
+ +
+ + +
    +
  1. {{ task }}
  2. +
+
+
+
+
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 index 000000000..43f336f59 --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.component.scss @@ -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 index 000000000..93d7ffcec --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.component.ts @@ -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 => `
  • ${t}
  • `).join('') + + // eslint-disable-next-line max-len + const confirmHTML = $localize`The current video will be overwritten by this edited video and you won't be able to recover it.

    ` + + $localize`As a reminder, the following tasks will be executed:
      ${listHTML}
    ` + + 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 index 000000000..7b95ae834 --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts @@ -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 { + 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 index 000000000..5a9e9fdd0 --- /dev/null +++ b/client/src/app/+video-editor/index.ts @@ -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 index 000000000..eaf88b6f4 --- /dev/null +++ b/client/src/app/+video-editor/shared/index.ts @@ -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 index 000000000..5b7053039 --- /dev/null +++ b/client/src/app/+video-editor/shared/video-editor.service.ts @@ -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 index 000000000..9f37a0dae --- /dev/null +++ b/client/src/app/+video-editor/video-editor-routing.module.ts @@ -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 index 000000000..7bbebc17b --- /dev/null +++ b/client/src/app/+video-editor/video-editor.module.ts @@ -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 { } diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts index e59238ffe..6e8a64f46 100644 --- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts +++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts @@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges { playlist: false, download: true, update: true, + editor: true, blacklist: true, delete: true, report: true, diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html index 0c4d46714..c6ffb1abd 100644 --- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html +++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html @@ -14,6 +14,10 @@ The video is being transcoded, it may not work properly. +
    + The video is being edited, it may not work properly. +
    +
    The video is being moved to an external server, it may not work properly.
    diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts index a3d3fa6fb..79b56705f 100644 --- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts +++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts @@ -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 } diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index b5afc9c92..cd499845b 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts @@ -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 => { diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts index 07a12c6f6..6b3a6c773 100644 --- a/client/src/app/shared/shared-forms/form-reactive.ts +++ b/client/src/app/shared/shared-forms/form-reactive.ts @@ -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() diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts index 0fe50ac9b..f67d5bb33 100644 --- a/client/src/app/shared/shared-forms/form-validator.service.ts +++ b/client/src/app/shared/shared-forms/form-validator.service.ts @@ -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 ] diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html index c57a4b32c..c89a7b019 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.html +++ b/client/src/app/shared/shared-forms/timestamp-input.component.html @@ -1,4 +1,5 @@ diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss index d2358c027..27d6fa173 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.scss +++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss @@ -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); + } + } } diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts index 3fc705905..79ca63673 100644 --- a/client/src/app/shared/shared-forms/timestamp-input.component.ts +++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts @@ -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() diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts index c2a318285..abbfc63f8 100644 --- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts @@ -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(), diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts index 847e401ed..7de9fc8e2 100644 --- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts +++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts @@ -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 '' } diff --git a/config/default.yaml b/config/default.yaml index 23be08f85..1e7fb9e5b 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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: diff --git a/config/production.yaml.example b/config/production.yaml.example index 675801caa..d1f18ecde 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -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: diff --git a/config/test-1.yaml b/config/test-1.yaml index d5f8299e0..0f6d56f1a 100644 --- a/config/test-1.yaml +++ b/config/test-1.yaml @@ -37,6 +37,9 @@ signup: transcoding: enabled: false +video_editor: + enabled: false + live: rtmp: port: 1936 diff --git a/config/test-3.yaml b/config/test-3.yaml index 594439b62..3cd3ddba7 100644 --- a/config/test-3.yaml +++ b/config/test-3.yaml @@ -30,3 +30,6 @@ admin: transcoding: enabled: false + +video_editor: + enabled: false diff --git a/config/test-4.yaml b/config/test-4.yaml index 1e6368bf7..6d8e51945 100644 --- a/config/test-4.yaml +++ b/config/test-4.yaml @@ -30,3 +30,6 @@ admin: transcoding: enabled: false + +video_editor: + enabled: false diff --git a/config/test-5.yaml b/config/test-5.yaml index 97f18a7a0..5f2157fec 100644 --- a/config/test-5.yaml +++ b/config/test-5.yaml @@ -30,3 +30,6 @@ admin: transcoding: enabled: false + +video_editor: + enabled: false diff --git a/config/test-6.yaml b/config/test-6.yaml index 156da84d2..9c43d2b2e 100644 --- a/config/test-6.yaml +++ b/config/test-6.yaml @@ -30,3 +30,6 @@ admin: transcoding: enabled: false + +video_editor: + enabled: false diff --git a/config/test.yaml b/config/test.yaml index 461e1b4ba..99bf85143 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -164,3 +164,6 @@ views: local_buffer_update_interval: '5 seconds' ip_view_expiration: '1 second' + +video_editor: + enabled: true diff --git a/scripts/create-transcoding-job.ts b/scripts/create-transcoding-job.ts index c4b376431..59fc84ad5 100755 --- a/scripts/create-transcoding-job.ts +++ b/scripts/create-transcoding-job.ts @@ -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' diff --git a/scripts/print-transcode-command.ts b/scripts/print-transcode-command.ts index 21667f544..ef671c0aa 100644 --- a/scripts/print-transcode-command.ts +++ b/scripts/print-transcode-command.ts @@ -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('') @@ -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) diff --git a/server.ts b/server.ts index 385996470..bb7a0c210 100644 --- 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) diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 4e3dd4d80..821ed4ad3 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -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 index 000000000..61e2eb5da --- /dev/null +++ b/server/controllers/api/videos/editor.ts @@ -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 + } + } +} diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 61a030ba1..a5ae07d95 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -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) diff --git a/server/controllers/api/videos/transcoding.ts b/server/controllers/api/videos/transcoding.ts index fba4545c2..da3ea3c9c 100644 --- a/server/controllers/api/videos/transcoding.ts +++ b/server/controllers/api/videos/transcoding.ts @@ -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 diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts index fd90d9915..3c026ad1f 100644 --- a/server/controllers/api/videos/upload.ts +++ b/server/controllers/api/videos/upload.ts @@ -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) diff --git a/server/helpers/custom-validators/actor-images.ts b/server/helpers/custom-validators/actor-images.ts index 4fb0b7c70..89f5a2262 100644 --- a/server/helpers/custom-validators/actor-images.ts +++ b/server/helpers/custom-validators/actor-images.ts @@ -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 + }) } // --------------------------------------------------------------------------- diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts index 81a60ee66..c80c86193 100644 --- a/server/helpers/custom-validators/misc.ts +++ b/server/helpers/custom-validators/misc.ts @@ -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 } diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts index 4cc7dcaf4..59ba005fe 100644 --- a/server/helpers/custom-validators/video-captions.ts +++ b/server/helpers/custom-validators/video-captions.ts @@ -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 index 000000000..09238675e --- /dev/null +++ b/server/helpers/custom-validators/video-editor.ts @@ -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 +} diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts index dbf6a3504..af93aea56 100644 --- a/server/helpers/custom-validators/video-imports.ts +++ b/server/helpers/custom-validators/video-imports.ts @@ -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 + }) } // --------------------------------------------------------------------------- diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts index e526c4284..ca5f70fdc 100644 --- a/server/helpers/custom-validators/videos.ts +++ b/server/helpers/custom-validators/videos.ts @@ -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 } diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts index 780fd6345..08f77966f 100644 --- a/server/helpers/express-utils.ts +++ b/server/helpers/express-utils.ts @@ -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 index 78ee5fa7f..000000000 --- a/server/helpers/ffmpeg-utils.ts +++ /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 -async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise> { - if (supportedEncoders !== undefined) { - return supportedEncoders - } - - const getAvailableEncodersPromise = promisify0(getAvailableEncoders) - const availableFFmpegEncoders = await getAvailableEncodersPromise() - - const searchEncoders = new Set() - 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() - - 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 { - 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 { - 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((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 -} = { - '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 = 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((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((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 index 000000000..ee338889c --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-commons.ts @@ -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((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((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 index 000000000..a5baa7ef1 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-edition.ts @@ -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 index 000000000..5bd80ba05 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-encoders.ts @@ -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 +async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise> { + if (supportedEncoders !== undefined) { + return supportedEncoders + } + + const getAvailableEncodersPromise = promisify0(getAvailableEncoders) + const availableFFmpegEncoders = await getAvailableEncodersPromise() + + const searchEncoders = new Set() + 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() + + 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 = 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 index 000000000..7f64c6d0a --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-images.ts @@ -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 { + 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 { + 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((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 index 000000000..ff571626c --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-live.ts @@ -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 index 000000000..99b39f79a --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-presets.ts @@ -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 index 000000000..c3622ceb1 --- /dev/null +++ b/server/helpers/ffmpeg/ffmpeg-vod.ts @@ -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 +} = { + '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' +} diff --git a/server/helpers/ffprobe-utils.ts b/server/helpers/ffmpeg/ffprobe-utils.ts similarity index 67% rename from server/helpers/ffprobe-utils.ts rename to server/helpers/ffmpeg/ffprobe-utils.ts index 595112bce..07bcf01f4 100644 --- a/server/helpers/ffprobe-utils.ts +++ b/server/helpers/ffmpeg/ffprobe-utils.ts @@ -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 { if (CONFIG.TRANSCODING.PROFILE !== 'default') return false @@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise { await canDoQuickAudioTranscode(path, probe) } +async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise { + 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 { - 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 > (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 index 000000000..e3bb2013f --- /dev/null +++ b/server/helpers/ffmpeg/index.ts @@ -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' diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts index b174ae436..6e4a2b000 100644 --- a/server/helpers/image-utils.ts +++ b/server/helpers/image-utils.ts @@ -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 } diff --git a/server/helpers/webtorrent.ts b/server/helpers/webtorrent.ts index 68d532c48..88bdb16b6 100644 --- a/server/helpers/webtorrent.ts +++ b/server/helpers/webtorrent.ts @@ -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 } diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts index 57ef0d218..635a32010 100644 --- a/server/initializers/checker-after-init.ts +++ b/server/initializers/checker-after-init.ts @@ -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([ '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 -} diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 458005b98..d9d90d4b4 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -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', diff --git a/server/initializers/config.ts b/server/initializers/config.ts index fb6f7ae62..c1b82d12f 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -324,6 +324,9 @@ const CONFIG = { } } }, + VIDEO_EDITOR: { + get ENABLED () { return config.get('video_editor.enabled') } + }, IMPORT: { VIDEOS: { get CONCURRENCY () { return config.get('import.videos.concurrency') }, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9b972b87e..4d2a6fc63 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -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 { const oldTorrentName = uuid + '.torrent' const newTorrentName = uuid + '-' + resolution + '.torrent' diff --git a/server/lib/hls.ts b/server/lib/hls.ts index 985f50587..43043315b 100644 --- a/server/lib/hls.ts +++ b/server/lib/hls.ts @@ -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 index 000000000..c5ba0452f --- /dev/null +++ b/server/lib/job-queue/handlers/video-edition.ts @@ -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 = { + inputPath: string + outputPath: string + video: MVideo + task: T +} + +const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise } = { + '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) { + 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) { + const { task } = options + + return cutVideo({ + ...pick(options, [ 'inputPath', 'outputPath' ]), + + start: task.options.start, + end: task.options.end + }) +} + +function processAddWatermark (options: TaskProcessorOptions) { + 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') + } +} diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts index 0d9e80cb8..6b2d60317 100644 --- a/server/lib/job-queue/handlers/video-file-import.ts +++ b/server/lib/job-queue/handlers/video-file-import.ts @@ -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) diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index b6e05d8f5..b3ca28c2f 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -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, 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) diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index a04cfa2c9..497f6612a 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -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 diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 5540b791d..512979734 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -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 @@ -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) diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 22bd1f5d2..e10a3bab5 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -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 } = { '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 { diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts index 33e49acc1..21c34a9a4 100644 --- a/server/lib/live/live-manager.ts +++ b/server/lib/live/live-manager.ts @@ -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( diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts index 22a47942a..f5f473039 100644 --- a/server/lib/live/shared/muxing-session.ts +++ b/server/lib/live/shared/muxing-session.ts @@ -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' diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts index 78e4a28ad..897271c0b 100644 --- a/server/lib/plugins/plugin-helpers-builder.ts +++ b/server/lib/plugins/plugin-helpers-builder.ts @@ -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' diff --git a/server/lib/plugins/register-helpers.ts b/server/lib/plugins/register-helpers.ts index d1756040a..f4d405676 100644 --- a/server/lib/plugins/register-helpers.ts +++ b/server/lib/plugins/register-helpers.ts @@ -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 { diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts index d97f21eb7..38512f384 100644 --- a/server/lib/server-config-manager.ts +++ b/server/lib/server-config-manager.ts @@ -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: { diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts index 36270e5c1..aa2d7a813 100644 --- a/server/lib/thumbnail.ts +++ b/server/lib/thumbnail.ts @@ -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' diff --git a/server/lib/transcoding/video-transcoding-profiles.ts b/server/lib/transcoding/default-transcoding-profiles.ts similarity index 91% rename from server/lib/transcoding/video-transcoding-profiles.ts rename to server/lib/transcoding/default-transcoding-profiles.ts index dcc8d4c5c..ba98a11ca 100644 --- a/server/lib/transcoding/video-transcoding-profiles.ts +++ b/server/lib/transcoding/default-transcoding-profiles.ts @@ -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 diff --git a/server/lib/transcoding/video-transcoding.ts b/server/lib/transcoding/transcoding.ts similarity index 92% rename from server/lib/transcoding/video-transcoding.ts rename to server/lib/transcoding/transcoding.ts index 9942a067b..d55364e25 100644 --- a/server/lib/transcoding/video-transcoding.ts +++ b/server/lib/transcoding/transcoding.ts @@ -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) diff --git a/server/lib/user.ts b/server/lib/user.ts index 0d292ac90..3f7499296 100644 --- a/server/lib/user.ts +++ b/server/lib/user.ts @@ -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 index 000000000..99b0bd949 --- /dev/null +++ b/server/lib/video-editor.ts @@ -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 +} diff --git a/server/lib/video.ts b/server/lib/video.ts index 2690f953d..ec4256c1a 100644 --- a/server/lib/video.ts +++ b/server/lib/video.ts @@ -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 } } diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index 8b14feb3c..e87b2e39d 100644 --- a/server/middlewares/validators/config.ts +++ b/server/middlewares/validators/config.ts @@ -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 +} diff --git a/server/middlewares/validators/shared/utils.ts b/server/middlewares/validators/shared/utils.ts index 104eace91..410de4d80 100644 --- a/server/middlewares/validators/shared/utils.ts +++ b/server/middlewares/validators/shared/utils.ts @@ -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, diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts index fc978b63a..8807435f6 100644 --- a/server/middlewares/validators/shared/videos.ts +++ b/server/middlewares/validators/shared/videos.ts @@ -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 } diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts index f365d8ee1..faa082510 100644 --- a/server/middlewares/validators/videos/index.ts +++ b/server/middlewares/validators/videos/index.ts @@ -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' diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts index a399871e1..441c6b4be 100644 --- a/server/middlewares/validators/videos/video-captions.ts +++ b/server/middlewares/validators/videos/video-captions.ts @@ -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() } diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index 91e85711d..96d956035 100644 --- a/server/middlewares/validators/videos/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts @@ -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 index 000000000..9be97be93 --- /dev/null +++ b/server/middlewares/validators/videos/video-editor.ts @@ -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[]) +} diff --git a/server/middlewares/validators/videos/video-ownership-changes.ts b/server/middlewares/validators/videos/video-ownership-changes.ts index 95e4cebce..6dcdc05f5 100644 --- a/server/middlewares/validators/videos/video-ownership-changes.ts +++ b/server/middlewares/validators/videos/video-ownership-changes.ts @@ -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 } diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts index f5fee845e..241b9ed7b 100644 --- a/server/middlewares/validators/videos/video-playlists.ts +++ b/server/middlewares/validators/videos/video-playlists.ts @@ -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(', ') diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index b3ffb7007..26597cf7b 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -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`) diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 5536334eb..a4093ce3b 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -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>> { 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>> { return { audioStream, - ...await getVideoFileResolution(originalFilePath, probe) + ...await getVideoStreamDimensionsInfo(originalFilePath, probe) } }) } diff --git a/server/tests/api/activitypub/refresher.ts b/server/tests/api/activitypub/refresher.ts index 71e1c40ba..bb81d4565 100644 --- a/server/tests/api/activitypub/refresher.ts +++ b/server/tests/api/activitypub/refresher.ts @@ -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 diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index 3cccb612a..ce067a892 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts @@ -145,6 +145,9 @@ describe('Test config API validators', function () { } } }, + videoEditor: { + enabled: true + }, import: { videos: { concurrency: 1, diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index e052296db..c088b52cd 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -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 index 000000000..db284a3cc --- /dev/null +++ b/server/tests/api/check-params/video-editor.ts @@ -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 ]) + }) +}) diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts index 3f9355d2d..d756a02c1 100644 --- a/server/tests/api/live/live.ts +++ b/server/tests/api/live/live.ts @@ -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]) diff --git a/server/tests/api/search/search-channels.ts b/server/tests/api/search/search-channels.ts index 0073c71e1..cd4c053d2 100644 --- a/server/tests/api/search/search-channels.ts +++ b/server/tests/api/search/search-channels.ts @@ -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 = { diff --git a/server/tests/api/search/search-playlists.ts b/server/tests/api/search/search-playlists.ts index fcf2f2ee2..d9f12d316 100644 --- a/server/tests/api/search/search-playlists.ts +++ b/server/tests/api/search/search-playlists.ts @@ -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 diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index 2356f701c..565b2953a 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -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, diff --git a/server/tests/api/server/stats.ts b/server/tests/api/server/stats.ts index f0334532b..2296c0cb9 100644 --- a/server/tests/api/server/stats.ts +++ b/server/tests/api/server/stats.ts @@ -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() diff --git a/server/tests/api/videos/audio-only.ts b/server/tests/api/videos/audio-only.ts index e58360ffe..e7e73d382 100644 --- a/server/tests/api/videos/audio-only.ts +++ b/server/tests/api/videos/audio-only.ts @@ -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 } }) diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index bedb9b8b6..72e6ae2b4 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -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 index 000000000..a9b6950cc --- /dev/null +++ b/server/tests/api/videos/video-editor.ts @@ -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) + }) +}) diff --git a/server/tests/api/videos/video-playlist-thumbnails.ts b/server/tests/api/videos/video-playlist-thumbnails.ts index 5fdb0fc03..3944dc344 100644 --- a/server/tests/api/videos/video-playlist-thumbnails.ts +++ b/server/tests/api/videos/video-playlist-thumbnails.ts @@ -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]) diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts index 1e8dbef02..c33a63df0 100644 --- a/server/tests/api/videos/video-playlists.ts +++ b/server/tests/api/videos/video-playlists.ts @@ -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 diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts index d24a8f4e1..245c4c012 100644 --- a/server/tests/api/videos/video-transcoder.ts +++ b/server/tests/api/videos/video-transcoder.ts @@ -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) { diff --git a/server/tests/cli/update-host.ts b/server/tests/cli/update-host.ts index da89ff153..7c49efd20 100644 --- a/server/tests/cli/update-host.ts +++ b/server/tests/cli/update-host.ts @@ -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) diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts index 52ba396e5..7adfc1277 100644 --- a/server/tests/plugins/filter-hooks.ts +++ b/server/tests/plugins/filter-hooks.ts @@ -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' ]) { { diff --git a/server/tests/plugins/plugin-transcoding.ts b/server/tests/plugins/plugin-transcoding.ts index 5ab686472..49569f1fa 100644 --- a/server/tests/plugins/plugin-transcoding.ts +++ b/server/tests/plugins/plugin-transcoding.ts @@ -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') }) }) diff --git a/server/tests/shared/generate.ts b/server/tests/shared/generate.ts index f806df2f5..9a57084e4 100644 --- a/server/tests/shared/generate.ts +++ b/server/tests/shared/generate.ts @@ -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) diff --git a/server/tests/shared/videos.ts b/server/tests/shared/videos.ts index 6be094f2b..989865a49 100644 --- a/server/tests/shared/videos.ts +++ b/server/tests/shared/videos.ts @@ -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 } diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 1a99b598a..91a8cf3d8 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -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 = { diff --git a/shared/extra-utils/ffprobe.ts b/shared/extra-utils/ffprobe.ts index 53a3aa001..dfacd251c 100644 --- a/shared/extra-utils/ffprobe.ts +++ b/shared/extra-utils/ffprobe.ts @@ -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 { - const metadata = await getMetadataFromFile(path, existingProbe) +async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise { + 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 { - 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 } diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 52d3d9588..c9e7654de 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -143,6 +143,10 @@ export interface CustomConfig { } } + videoEditor: { + enabled: boolean + } + import: { videos: { concurrency: number diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 1519d1c3e..d0293f542 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts @@ -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[] +} diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 32be96b9d..0fe8b0de8 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -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 index 000000000..3436f2c3f --- /dev/null +++ b/shared/models/videos/editor/index.ts @@ -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 index 000000000..36b7c8d55 --- /dev/null +++ b/shared/models/videos/editor/video-editor-create-edit.model.ts @@ -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 + } +} diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 67614efc9..e8eb227ab 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -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' diff --git a/shared/models/videos/transcoding/video-transcoding-fps.model.ts b/shared/models/videos/transcoding/video-transcoding-fps.model.ts index 25fc1c2da..9a330ac94 100644 --- a/shared/models/videos/transcoding/video-transcoding-fps.model.ts +++ b/shared/models/videos/transcoding/video-transcoding-fps.model.ts @@ -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 diff --git a/shared/models/videos/transcoding/video-transcoding.model.ts b/shared/models/videos/transcoding/video-transcoding.model.ts index 3a7fb6472..91eacf8dc 100644 --- a/shared/models/videos/transcoding/video-transcoding.model.ts +++ b/shared/models/videos/transcoding/video-transcoding.model.ts @@ -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 diff --git a/shared/models/videos/video-state.enum.ts b/shared/models/videos/video-state.enum.ts index 09268d2ff..e45e4adc2 100644 --- a/shared/models/videos/video-state.enum.ts +++ b/shared/models/videos/video-state.enum.ts @@ -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 } diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts index 797231b1d..c0042060b 100644 --- a/shared/server-commands/server/config-command.ts +++ b/shared/server-commands/server/config-command.ts @@ -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 }) { - 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, diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index da89fd876..af4423e8d 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts @@ -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) } } diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts index 68a188b21..154aed9a6 100644 --- a/shared/server-commands/videos/index.ts +++ b/shared/server-commands/videos/index.ts @@ -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 index 000000000..485edce8e --- /dev/null +++ b/shared/server-commands/videos/video-editor-command.ts @@ -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 + }) + } +} -- 2.41.0