aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-02-11 10:51:33 +0100
committerChocobozzz <chocobozzz@cpy.re>2022-02-28 10:42:19 +0100
commitc729caf6cc34630877a0e5a1bda1719384cd0c8a (patch)
tree1d2e13722e518c73d2c9e6f0969615e29d51cf8c
parenta24bf4dc659cebb65d887862bf21d7a35e9ec791 (diff)
downloadPeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.gz
PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.zst
PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.zip
Add basic video editor support
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts3
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts9
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.scss1
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts8
-rw-r--r--client/src/app/+video-editor/edit/index.ts2
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.html88
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.scss76
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.component.ts202
-rw-r--r--client/src/app/+video-editor/edit/video-editor-edit.resolver.ts18
-rw-r--r--client/src/app/+video-editor/index.ts1
-rw-r--r--client/src/app/+video-editor/shared/index.ts1
-rw-r--r--client/src/app/+video-editor/shared/video-editor.service.ts28
-rw-r--r--client/src/app/+video-editor/video-editor-routing.module.ts30
-rw-r--r--client/src/app/+video-editor/video-editor.module.ts27
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.html4
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts4
-rw-r--r--client/src/app/app-routing.module.ts6
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.ts2
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts2
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.html3
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.scss14
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts23
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts4
-rw-r--r--config/default.yaml4
-rw-r--r--config/production.yaml.example4
-rw-r--r--config/test-1.yaml3
-rw-r--r--config/test-3.yaml3
-rw-r--r--config/test-4.yaml3
-rw-r--r--config/test-5.yaml3
-rw-r--r--config/test-6.yaml3
-rw-r--r--config/test.yaml3
-rwxr-xr-xscripts/create-transcoding-job.ts2
-rw-r--r--scripts/print-transcode-command.ts8
-rw-r--r--server.ts5
-rw-r--r--server/controllers/api/config.ts3
-rw-r--r--server/controllers/api/videos/editor.ts120
-rw-r--r--server/controllers/api/videos/index.ts2
-rw-r--r--server/controllers/api/videos/transcoding.ts4
-rw-r--r--server/controllers/api/videos/upload.ts8
-rw-r--r--server/helpers/custom-validators/actor-images.ts11
-rw-r--r--server/helpers/custom-validators/misc.ts79
-rw-r--r--server/helpers/custom-validators/video-captions.ts12
-rw-r--r--server/helpers/custom-validators/video-editor.ts52
-rw-r--r--server/helpers/custom-validators/video-imports.ts11
-rw-r--r--server/helpers/custom-validators/videos.ts27
-rw-r--r--server/helpers/express-utils.ts77
-rw-r--r--server/helpers/ffmpeg-utils.ts781
-rw-r--r--server/helpers/ffmpeg/ffmpeg-commons.ts114
-rw-r--r--server/helpers/ffmpeg/ffmpeg-edition.ts242
-rw-r--r--server/helpers/ffmpeg/ffmpeg-encoders.ts116
-rw-r--r--server/helpers/ffmpeg/ffmpeg-images.ts46
-rw-r--r--server/helpers/ffmpeg/ffmpeg-live.ts161
-rw-r--r--server/helpers/ffmpeg/ffmpeg-presets.ts156
-rw-r--r--server/helpers/ffmpeg/ffmpeg-vod.ts254
-rw-r--r--server/helpers/ffmpeg/ffprobe-utils.ts (renamed from server/helpers/ffprobe-utils.ts)97
-rw-r--r--server/helpers/ffmpeg/index.ts8
-rw-r--r--server/helpers/image-utils.ts28
-rw-r--r--server/helpers/webtorrent.ts39
-rw-r--r--server/initializers/checker-after-init.ts188
-rw-r--r--server/initializers/checker-before-init.ts2
-rw-r--r--server/initializers/config.ts3
-rw-r--r--server/initializers/constants.ts22
-rw-r--r--server/initializers/migrations/0075-video-resolutions.ts8
-rw-r--r--server/lib/hls.ts6
-rw-r--r--server/lib/job-queue/handlers/video-edition.ts229
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts12
-rw-r--r--server/lib/job-queue/handlers/video-import.ts8
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts8
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts10
-rw-r--r--server/lib/job-queue/job-queue.ts9
-rw-r--r--server/lib/live/live-manager.ts14
-rw-r--r--server/lib/live/shared/muxing-session.ts4
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts2
-rw-r--r--server/lib/plugins/register-helpers.ts2
-rw-r--r--server/lib/server-config-manager.ts5
-rw-r--r--server/lib/thumbnail.ts5
-rw-r--r--server/lib/transcoding/default-transcoding-profiles.ts (renamed from server/lib/transcoding/video-transcoding-profiles.ts)25
-rw-r--r--server/lib/transcoding/transcoding.ts (renamed from server/lib/transcoding/video-transcoding.ts)35
-rw-r--r--server/lib/user.ts6
-rw-r--r--server/lib/video-editor.ts32
-rw-r--r--server/lib/video.ts6
-rw-r--r--server/middlewares/validators/config.ts14
-rw-r--r--server/middlewares/validators/shared/utils.ts1
-rw-r--r--server/middlewares/validators/shared/videos.ts26
-rw-r--r--server/middlewares/validators/videos/index.ts1
-rw-r--r--server/middlewares/validators/videos/video-captions.ts10
-rw-r--r--server/middlewares/validators/videos/video-comments.ts14
-rw-r--r--server/middlewares/validators/videos/video-editor.ts112
-rw-r--r--server/middlewares/validators/videos/video-ownership-changes.ts21
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts4
-rw-r--r--server/middlewares/validators/videos/videos.ts40
-rw-r--r--server/models/video/video.ts6
-rw-r--r--server/tests/api/activitypub/refresher.ts6
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/video-editor.ts385
-rw-r--r--server/tests/api/live/live.ts4
-rw-r--r--server/tests/api/search/search-channels.ts4
-rw-r--r--server/tests/api/search/search-playlists.ts4
-rw-r--r--server/tests/api/server/config.ts7
-rw-r--r--server/tests/api/server/stats.ts8
-rw-r--r--server/tests/api/videos/audio-only.ts7
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/video-editor.ts368
-rw-r--r--server/tests/api/videos/video-playlist-thumbnails.ts6
-rw-r--r--server/tests/api/videos/video-playlists.ts6
-rw-r--r--server/tests/api/videos/video-transcoder.ts39
-rw-r--r--server/tests/cli/update-host.ts3
-rw-r--r--server/tests/plugins/filter-hooks.ts8
-rw-r--r--server/tests/plugins/plugin-transcoding.ts10
-rw-r--r--server/tests/shared/generate.ts8
-rw-r--r--server/tests/shared/videos.ts13
-rw-r--r--server/types/express.d.ts3
-rw-r--r--shared/extra-utils/ffprobe.ts96
-rw-r--r--shared/models/server/custom-config.model.ts4
-rw-r--r--shared/models/server/job.model.ts39
-rw-r--r--shared/models/server/server-config.model.ts4
-rw-r--r--shared/models/videos/editor/index.ts1
-rw-r--r--shared/models/videos/editor/video-editor-create-edit.model.ts42
-rw-r--r--shared/models/videos/index.ts1
-rw-r--r--shared/models/videos/transcoding/video-transcoding-fps.model.ts1
-rw-r--r--shared/models/videos/transcoding/video-transcoding.model.ts7
-rw-r--r--shared/models/videos/video-state.enum.ts3
-rw-r--r--shared/server-commands/server/config-command.ts34
-rw-r--r--shared/server-commands/server/server.ts3
-rw-r--r--shared/server-commands/videos/index.ts1
-rw-r--r--shared/server-commands/videos/video-editor-command.ts67
130 files changed, 3888 insertions, 1272 deletions
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 {
197 resolutions: {} 197 resolutions: {}
198 } 198 }
199 }, 199 },
200 videoEditor: {
201 enabled: null
202 },
200 autoBlacklist: { 203 autoBlacklist: {
201 videos: { 204 videos: {
202 ofUsers: { 205 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 @@
192 192
193 </div> 193 </div>
194 </div> 194 </div>
195
196 <div class="form-row mt-2"> <!-- video editor grid -->
197 <div class="form-group col-12 col-lg-4 col-xl-3">
198 <div i18n class="inner-form-title">VIDEO EDITOR</div>
199 <div i18n class="inner-form-description">
200 Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
201 </div>
202 </div>
203
204 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
205
206 <ng-container formGroupName="videoEditor">
207 <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
208 <my-peertube-checkbox
209 inputName="videoEditorEnabled" formControlName="enabled"
210 i18n-labelText labelText="Enable video editor"
211 >
212 <ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
213 <span i18n>⚠️ You need to enable transcoding first to enable video editor</span>
214 </ng-container>
215 </my-peertube-checkbox>
216 </div>
217 </ng-container>
218 </div>
219 </div>
195</ng-container> 220</ng-container>
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 {
71 } 71 }
72 72
73 private checkTranscodingFields () { 73 private checkTranscodingFields () {
74 const transcodingControl = this.form.get('transcoding.enabled')
75 const videoEditorControl = this.form.get('videoEditor.enabled')
74 const hlsControl = this.form.get('transcoding.hls.enabled') 76 const hlsControl = this.form.get('transcoding.hls.enabled')
75 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled') 77 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
76 78
@@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
95 webtorrentControl.enable() 97 webtorrentControl.enable()
96 } 98 }
97 }) 99 })
100
101 transcodingControl.valueChanges
102 .subscribe(newValue => {
103 if (newValue === false) {
104 videoEditorControl.setValue(false)
105 }
106 })
98 } 107 }
99} 108}
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 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3
3my-embed { 4my-embed {
4 display: block; 5 display: block;
5 max-width: 500px; 6 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'
9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 9import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 10import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature' 11import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
12import { VideoChannel, VideoSortField } from '@shared/models' 12import { VideoChannel, VideoSortField, VideoState } from '@shared/models'
13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' 13import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
14 14
15@Component({ 15@Component({
@@ -205,6 +205,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
205 private buildActions () { 205 private buildActions () {
206 this.videoActions = [ 206 this.videoActions = [
207 { 207 {
208 label: $localize`Editor`,
209 linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
210 isDisplayed: ({ video }) => video.state.id === VideoState.PUBLISHED,
211 iconName: 'film'
212 },
213 {
208 label: $localize`Display live information`, 214 label: $localize`Display live information`,
209 handler: ({ video }) => this.displayLiveInformation(video), 215 handler: ({ video }) => this.displayLiveInformation(video),
210 isDisplayed: ({ video }) => video.isLive, 216 isDisplayed: ({ video }) => video.isLive,
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 @@
1export * from './video-editor-edit.component'
2export * 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 @@
1<div class="margin-content">
2 <h1 class="title-page title-page-single" i18n>Edit {{ video.name }}</h1>
3
4 <div class="columns">
5 <form role="form" [formGroup]="form">
6
7 <div class="section cut" formGroupName="cut">
8 <h2 i18n>CUT VIDEO</h2>
9
10 <div i18n class="description">Set a new start/end.</div>
11
12 <div class="form-group">
13 <label i18n for="cutStart">New start</label>
14 <my-timestamp-input inputName="cutStart" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="start"></my-timestamp-input>
15 </div>
16
17 <div class="form-group">
18 <label i18n for="cutEnd">New end</label>
19 <my-timestamp-input inputName="cutEnd" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="end"></my-timestamp-input>
20 </div>
21 </div>
22
23 <div class="section" formGroupName="add-intro">
24 <h2 i18n>ADD INTRO</h2>
25
26 <div i18n class="description">Concatenate a file at the beginning of the video.</div>
27
28 <div class="form-group">
29 <my-reactive-file
30 formControlName="file" inputName="addIntroFile" i18n-inputLabel inputLabel="Select the intro video file"
31 [extensions]="videoExtensions" [displayFilename]="true"
32 [ngbTooltip]="getIntroOutroTooltip()"
33 ></my-reactive-file>
34 </div>
35 </div>
36
37 <div class="section" formGroupName="add-outro">
38 <h2 i18n>ADD OUTRO</h2>
39
40 <div i18n class="description">Concatenate a file at the end of the video.</div>
41
42 <div class="form-group">
43 <my-reactive-file
44 formControlName="file" inputName="addOutroFile" i18n-inputLabel inputLabel="Select the outro video file"
45 [extensions]="videoExtensions" [displayFilename]="true"
46 [ngbTooltip]="getIntroOutroTooltip()"
47 ></my-reactive-file>
48 </div>
49 </div>
50
51 <div class="section" formGroupName="add-watermark">
52 <h2 i18n>ADD WATERMARK</h2>
53
54 <div i18n class="description">Add a watermark image to the video.</div>
55
56 <div class="form-group">
57 <my-reactive-file
58 formControlName="file" inputName="addWatermarkFile" i18n-inputLabel inputLabel="Select watermark image file"
59 [extensions]="imageExtensions" [displayFilename]="true"
60 [ngbTooltip]="getWatermarkTooltip()"
61 ></my-reactive-file>
62 </div>
63 </div>
64
65 <my-button
66 className="orange-button" i18n-label label="Run video edition" icon="circle-tick"
67 (click)="runEdition()" (keydown.enter)="runEdition()"
68 [disabled]="!form.valid || isRunningEdition || noEdition()"
69 ></my-button>
70 </form>
71
72
73 <div class="information">
74 <div>
75 <label i18n>Video before edition</label>
76 <my-embed [video]="video"></my-embed>
77 </div>
78
79 <div *ngIf="!noEdition()">
80 <label i18n>Edition tasks:</label>
81
82 <ol>
83 <li *ngFor="let task of getTasksSummary()">{{ task }}</li>
84 </ol>
85 </div>
86 </div>
87 </div>
88</div>
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.scss b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
new file mode 100644
index 000000000..43f336f59
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
@@ -0,0 +1,76 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.columns {
5 display: flex;
6
7 .information {
8 width: 100%;
9 margin-left: 50px;
10
11 > div {
12 margin-bottom: 30px;
13 }
14
15 @media screen and (max-width: $small-view) {
16 display: none;
17 }
18 }
19}
20
21h1 {
22 font-size: 20px;
23}
24
25h2 {
26 font-weight: $font-bold;
27 font-size: 16px;
28 color: pvar(--mainColor);
29 background-color: pvar(--mainBackgroundColor);
30 padding: 0 5px;
31 width: fit-content;
32 margin: -8px 0 0;
33}
34
35.section {
36 $min-width: 600px;
37
38 @include padding-left(10px);
39
40 min-width: $min-width;
41
42 margin-bottom: 50px;
43 border: 1px solid $separator-border-color;
44 border-radius: 5px;
45 width: fit-content;
46
47 .form-group,
48 .description {
49 @include margin-left(5px);
50 }
51
52 .description {
53 color: pvar(--greyForegroundColor);
54 margin-top: 5px;
55 margin-bottom: 15px;
56 }
57
58 @media screen and (max-width: $min-width) {
59 min-width: none;
60 }
61}
62
63my-timestamp-input {
64 display: block;
65}
66
67my-embed {
68 display: block;
69 max-width: 500px;
70 width: 100%;
71}
72
73my-reactive-file {
74 display: block;
75 width: fit-content;
76}
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 @@
1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { ConfirmService, Notifier, ServerService } from '@app/core'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { Video, VideoDetails } from '@app/shared/shared-main'
6import { LoadingBarService } from '@ngx-loading-bar/core'
7import { secondsToTime } from '@shared/core-utils'
8import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
9import { VideoEditorService } from '../shared'
10
11@Component({
12 selector: 'my-video-editor-edit',
13 templateUrl: './video-editor-edit.component.html',
14 styleUrls: [ './video-editor-edit.component.scss' ]
15})
16export class VideoEditorEditComponent extends FormReactive implements OnInit {
17 isRunningEdition = false
18
19 video: VideoDetails
20
21 constructor (
22 protected formValidatorService: FormValidatorService,
23 private serverService: ServerService,
24 private notifier: Notifier,
25 private router: Router,
26 private route: ActivatedRoute,
27 private videoEditorService: VideoEditorService,
28 private loadingBar: LoadingBarService,
29 private confirmService: ConfirmService
30 ) {
31 super()
32 }
33
34 ngOnInit () {
35 this.video = this.route.snapshot.data.video
36
37 const defaultValues = {
38 cut: {
39 start: 0,
40 end: this.video.duration
41 }
42 }
43
44 this.buildForm({
45 cut: {
46 start: null,
47 end: null
48 },
49 'add-intro': {
50 file: null
51 },
52 'add-outro': {
53 file: null
54 },
55 'add-watermark': {
56 file: null
57 }
58 }, defaultValues)
59 }
60
61 get videoExtensions () {
62 return this.serverService.getHTMLConfig().video.file.extensions
63 }
64
65 get imageExtensions () {
66 return this.serverService.getHTMLConfig().video.image.extensions
67 }
68
69 async runEdition () {
70 if (this.isRunningEdition) return
71
72 const title = $localize`Are you sure you want to edit "${this.video.name}"?`
73 const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
74
75 // eslint-disable-next-line max-len
76 const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
77 $localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
78
79 if (await this.confirmService.confirm(confirmHTML, title) !== true) return
80
81 this.isRunningEdition = true
82
83 const tasks = this.buildTasks()
84
85 this.loadingBar.useRef().start()
86
87 return this.videoEditorService.editVideo(this.video.uuid, tasks)
88 .subscribe({
89 next: () => {
90 this.notifier.success($localize`Video updated.`)
91 this.router.navigateByUrl(Video.buildWatchUrl(this.video))
92 },
93
94 error: err => {
95 this.loadingBar.useRef().complete()
96 this.isRunningEdition = false
97 this.notifier.error(err.message)
98 console.error(err)
99 }
100 })
101 }
102
103 getIntroOutroTooltip () {
104 return $localize`(extensions: ${this.videoExtensions.join(', ')})`
105 }
106
107 getWatermarkTooltip () {
108 return $localize`(extensions: ${this.imageExtensions.join(', ')})`
109 }
110
111 noEdition () {
112 return this.buildTasks().length === 0
113 }
114
115 getTasksSummary () {
116 const tasks = this.buildTasks()
117
118 return tasks.map(t => {
119 if (t.name === 'add-intro') {
120 return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
121 }
122
123 if (t.name === 'add-outro') {
124 return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
125 }
126
127 if (t.name === 'add-watermark') {
128 return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
129 }
130
131 if (t.name === 'cut') {
132 const { start, end } = t.options
133
134 if (start !== undefined && end !== undefined) {
135 return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
136 }
137
138 if (start !== undefined) {
139 return $localize`Video will begin at ${secondsToTime(start)}`
140 }
141
142 if (end !== undefined) {
143 return $localize`Video will stop at ${secondsToTime(end)}`
144 }
145 }
146
147 return ''
148 })
149 }
150
151 private getFilename (obj: any) {
152 return obj.name
153 }
154
155 private buildTasks () {
156 const tasks: VideoEditorTask[] = []
157 const value = this.form.value
158
159 const cut = value['cut']
160 if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
161
162 const options: VideoEditorTaskCut['options'] = {}
163 if (cut['start'] !== 0) options.start = cut['start']
164 if (cut['end'] !== this.video.duration) options.end = cut['end']
165
166 tasks.push({
167 name: 'cut',
168 options
169 })
170 }
171
172 if (value['add-intro']?.['file']) {
173 tasks.push({
174 name: 'add-intro',
175 options: {
176 file: value['add-intro']['file']
177 }
178 })
179 }
180
181 if (value['add-outro']?.['file']) {
182 tasks.push({
183 name: 'add-outro',
184 options: {
185 file: value['add-outro']['file']
186 }
187 })
188 }
189
190 if (value['add-watermark']?.['file']) {
191 tasks.push({
192 name: 'add-watermark',
193 options: {
194 file: value['add-watermark']['file']
195 }
196 })
197 }
198
199 return tasks
200 }
201
202}
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 @@
1
2import { Injectable } from '@angular/core'
3import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
4import { VideoService } from '@app/shared/shared-main'
5
6@Injectable()
7export class VideoEditorEditResolver implements Resolve<any> {
8 constructor (
9 private videoService: VideoService
10 ) {
11 }
12
13 resolve (route: ActivatedRouteSnapshot) {
14 const videoId: string = route.params['videoId']
15
16 return this.videoService.getVideo({ videoId })
17 }
18}
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 @@
1import { catchError } from 'rxjs'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core'
5import { objectToFormData } from '@app/helpers'
6import { VideoService } from '@app/shared/shared-main'
7import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models'
8
9@Injectable()
10export class VideoEditorService {
11
12 constructor (
13 private authHttp: HttpClient,
14 private restExtractor: RestExtractor
15 ) {}
16
17 editVideo (videoId: number | string, tasks: VideoEditorTask[]) {
18 const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit'
19 const body: VideoEditorCreateEdition = {
20 tasks
21 }
22
23 const data = objectToFormData(body)
24
25 return this.authHttp.post(url, data)
26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 }
28}
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 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { VideoEditorEditResolver } from './edit'
4import { VideoEditorEditComponent } from './edit/video-editor-edit.component'
5
6const videoEditorRoutes: Routes = [
7 {
8 path: '',
9 children: [
10 {
11 path: 'edit/:videoId',
12 component: VideoEditorEditComponent,
13 data: {
14 meta: {
15 title: $localize`Edit video`
16 }
17 },
18 resolve: {
19 video: VideoEditorEditResolver
20 }
21 }
22 ]
23 }
24]
25
26@NgModule({
27 imports: [ RouterModule.forChild(videoEditorRoutes) ],
28 exports: [ RouterModule ]
29})
30export 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 @@
1import { NgModule } from '@angular/core'
2import { SharedFormModule } from '@app/shared/shared-forms'
3import { SharedMainModule } from '@app/shared/shared-main'
4import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit'
5import { VideoEditorService } from './shared'
6import { VideoEditorRoutingModule } from './video-editor-routing.module'
7
8@NgModule({
9 imports: [
10 VideoEditorRoutingModule,
11
12 SharedMainModule,
13 SharedFormModule
14 ],
15
16 declarations: [
17 VideoEditorEditComponent
18 ],
19
20 exports: [],
21
22 providers: [
23 VideoEditorService,
24 VideoEditorEditResolver
25 ]
26})
27export 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 {
35 playlist: false, 35 playlist: false,
36 download: true, 36 download: true,
37 update: true, 37 update: true,
38 editor: true,
38 blacklist: true, 39 blacklist: true,
39 delete: true, 40 delete: true,
40 report: true, 41 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 @@
14 The video is being transcoded, it may not work properly. 14 The video is being transcoded, it may not work properly.
15</div> 15</div>
16 16
17<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
18 The video is being edited, it may not work properly.
19</div>
20
17<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()"> 21<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
18 The video is being moved to an external server, it may not work properly. 22 The video is being moved to an external server, it may not work properly.
19</div> 23</div>
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 {
14 return this.video && this.video.state.id === VideoState.TO_TRANSCODE 14 return this.video && this.video.state.id === VideoState.TO_TRANSCODE
15 } 15 }
16 16
17 isVideoToEdit () {
18 return this.video && this.video.state.id === VideoState.TO_EDIT
19 }
20
17 isVideoTranscodingFailed () { 21 isVideoTranscodingFailed () {
18 return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED 22 return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
19 } 23 }
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 = [
143 canActivateChild: [ MetaGuard ] 143 canActivateChild: [ MetaGuard ]
144 }, 144 },
145 145
146 {
147 path: 'video-editor',
148 loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
149 canActivateChild: [ MetaGuard ]
150 },
151
146 // Matches /@:actorName 152 // Matches /@:actorName
147 { 153 {
148 matcher: (url): UrlMatchResult => { 154 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 {
24 this.formErrors = formErrors 24 this.formErrors = formErrors
25 this.validationMessages = validationMessages 25 this.validationMessages = validationMessages
26 26
27 this.form.statusChanges.subscribe(async status => { 27 this.form.statusChanges.subscribe(async () => {
28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed 28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
29 await this.waitPendingCheck() 29 await this.waitPendingCheck()
30 30
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 {
30 30
31 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } 31 if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
32 32
33 const defaultValue = defaultValues[name] || '' 33 const defaultValue = defaultValues[name] ?? ''
34 34
35 if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ] 35 if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
36 else group[name] = [ defaultValue ] 36 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 @@
1<p-inputMask 1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()" 2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" 3 [ngClass]="{ 'border-disabled': disableBorder }"
4 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
4></p-inputMask> 5></p-inputMask>
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 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *;
2 3
3p-inputmask { 4p-inputmask {
4 ::ng-deep input { 5 ::ng-deep input {
5 width: 80px; 6 width: 80px;
6 font-size: 15px; 7 font-size: 15px;
7 border: 0;
8 8
9 &:focus-within, 9 &:focus-within,
10 &:focus { 10 &:focus {
@@ -16,4 +16,16 @@ p-inputmask {
16 opacity: 0.5; 16 opacity: 0.5;
17 } 17 }
18 } 18 }
19
20 &.border-disabled {
21 ::ng-deep input {
22 border: 0;
23 }
24 }
25
26 &:not(.border-disabled) {
27 ::ng-deep input {
28 @include peertube-input-text(80px);
29 }
30 }
19} 31}
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 {
18 @Input() maxTimestamp: number 18 @Input() maxTimestamp: number
19 @Input() timestamp: number 19 @Input() timestamp: number
20 @Input() disabled = false 20 @Input() disabled = false
21 @Input() inputName: string
22 @Input() disableBorder = true
21 23
22 @Output() inputBlur = new EventEmitter() 24 @Output() inputBlur = new EventEmitter()
23 25
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 @@
1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
2import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core' 2import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation' 3import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 4import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
5import { VideoCaption } from '@shared/models' 5import { VideoCaption, VideoState } from '@shared/models'
6import { 6import {
7 Actor, 7 Actor,
8 DropdownAction, 8 DropdownAction,
@@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
29 liveInfo?: boolean 29 liveInfo?: boolean
30 removeFiles?: boolean 30 removeFiles?: boolean
31 transcoding?: boolean 31 transcoding?: boolean
32 editor?: boolean
32} 33}
33 34
34@Component({ 35@Component({
@@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
59 mute: true, 60 mute: true,
60 liveInfo: false, 61 liveInfo: false,
61 removeFiles: false, 62 removeFiles: false,
62 transcoding: false 63 transcoding: false,
64 editor: true
63 } 65 }
64 @Input() placement = 'left' 66 @Input() placement = 'left'
65 67
@@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
89 private videoBlocklistService: VideoBlockService, 91 private videoBlocklistService: VideoBlockService,
90 private screenService: ScreenService, 92 private screenService: ScreenService,
91 private videoService: VideoService, 93 private videoService: VideoService,
92 private redundancyService: RedundancyService 94 private redundancyService: RedundancyService,
95 private serverService: ServerService
93 ) { } 96 ) { }
94 97
95 get user () { 98 get user () {
@@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
149 return this.video.isUpdatableBy(this.user) 152 return this.video.isUpdatableBy(this.user)
150 } 153 }
151 154
155 isVideoEditable () {
156 return this.serverService.getHTMLConfig().videoEditor.enabled &&
157 this.video.state?.id === VideoState.PUBLISHED &&
158 this.video.isUpdatableBy(this.user)
159 }
160
152 isVideoRemovable () { 161 isVideoRemovable () {
153 return this.video.isRemovableBy(this.user) 162 return this.video.isRemovableBy(this.user)
154 } 163 }
@@ -330,6 +339,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
330 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable() 339 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
331 }, 340 },
332 { 341 {
342 label: $localize`Editor`,
343 linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
344 iconName: 'film',
345 isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.editor && this.isVideoEditable()
346 },
347 {
333 label: $localize`Block`, 348 label: $localize`Block`,
334 handler: () => this.showBlockModal(), 349 handler: () => this.showBlockModal(),
335 iconName: 'no', 350 iconName: 'no',
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 {
195 return $localize`To import` 195 return $localize`To import`
196 } 196 }
197 197
198 if (video.state.id === VideoState.TO_EDIT) {
199 return $localize`To edit`
200 }
201
198 return '' 202 return ''
199 } 203 }
200 204
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:
425 1440p: false 425 1440p: false
426 2160p: false 426 2160p: false
427 427
428video_editor:
429 # Enable video edition by users (cut, add intro/outro, add watermark etc)
430 enabled: false
431
428import: 432import:
429 # Add ability for your users to import remote videos (from YouTube, torrent...) 433 # Add ability for your users to import remote videos (from YouTube, torrent...)
430 videos: 434 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:
433 1440p: false 433 1440p: false
434 2160p: false 434 2160p: false
435 435
436video_editor:
437 # Enable video edition by users (cut, add intro/outro, add watermark etc)
438 enabled: false
439
436import: 440import:
437 # Add ability for your users to import remote videos (from YouTube, torrent...) 441 # Add ability for your users to import remote videos (from YouTube, torrent...)
438 videos: 442 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:
37transcoding: 37transcoding:
38 enabled: false 38 enabled: false
39 39
40video_editor:
41 enabled: false
42
40live: 43live:
41 rtmp: 44 rtmp:
42 port: 1936 45 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:
30 30
31transcoding: 31transcoding:
32 enabled: false 32 enabled: false
33
34video_editor:
35 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:
30 30
31transcoding: 31transcoding:
32 enabled: false 32 enabled: false
33
34video_editor:
35 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:
30 30
31transcoding: 31transcoding:
32 enabled: false 32 enabled: false
33
34video_editor:
35 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:
30 30
31transcoding: 31transcoding:
32 enabled: false 32 enabled: false
33
34video_editor:
35 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:
164 164
165 local_buffer_update_interval: '5 seconds' 165 local_buffer_update_interval: '5 seconds'
166 ip_view_expiration: '1 second' 166 ip_view_expiration: '1 second'
167
168video_editor:
169 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 @@
1import { program } from 'commander' 1import { program } from 'commander'
2import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc' 2import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
3import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils' 3import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
4import { CONFIG } from '@server/initializers/config' 4import { CONFIG } from '@server/initializers/config'
5import { addTranscodingJob } from '@server/lib/video' 5import { addTranscodingJob } from '@server/lib/video'
6import { VideoState, VideoTranscodingPayload } from '@shared/models' 6import { 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 @@
1import { program } from 'commander' 1import { program } from 'commander'
2import ffmpeg from 'fluent-ffmpeg' 2import ffmpeg from 'fluent-ffmpeg'
3import { exit } from 'process' 3import { exit } from 'process'
4import { buildx264VODCommand, runCommand, TranscodeOptions } from '@server/helpers/ffmpeg-utils' 4import { buildVODCommand, runCommand, TranscodeVODOptions } from '@server/helpers/ffmpeg'
5import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/video-transcoding-profiles' 5import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
6 6
7program 7program
8 .arguments('<path>') 8 .arguments('<path>')
@@ -33,12 +33,12 @@ async function run (path: string, cmd: any) {
33 33
34 resolution: +cmd.resolution, 34 resolution: +cmd.resolution,
35 isPortraitMode: false 35 isPortraitMode: false
36 } as TranscodeOptions 36 } as TranscodeVODOptions
37 37
38 let command = ffmpeg(options.inputPath) 38 let command = ffmpeg(options.inputPath)
39 .output(options.outputPath) 39 .output(options.outputPath)
40 40
41 command = await buildx264VODCommand(command, options) 41 command = await buildVODCommand(command, options)
42 42
43 command.on('start', (cmdline) => { 43 command.on('start', (cmdline) => {
44 console.log(cmdline) 44 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 {
42 42
43import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init' 43import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
44 44
45const errorMessage = checkConfig() 45checkConfig()
46if (errorMessage !== null) {
47 throw new Error(errorMessage)
48}
49 46
50// Trust our proxy (IP forwarding...) 47// Trust our proxy (IP forwarding...)
51app.set('trust proxy', CONFIG.TRUST_PROXY) 48app.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 {
256 } 256 }
257 } 257 }
258 }, 258 },
259 videoEditor: {
260 enabled: CONFIG.VIDEO_EDITOR.ENABLED
261 },
259 import: { 262 import: {
260 videos: { 263 videos: {
261 concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY, 264 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 @@
1import express from 'express'
2import { createAnyReqFiles } from '@server/helpers/express-utils'
3import { CONFIG } from '@server/initializers/config'
4import { MIMETYPES } from '@server/initializers/constants'
5import { JobQueue } from '@server/lib/job-queue'
6import { buildTaskFileFieldname, getTaskFile } from '@server/lib/video-editor'
7import {
8 HttpStatusCode,
9 VideoEditionTaskPayload,
10 VideoEditorCreateEdition,
11 VideoEditorTask,
12 VideoEditorTaskCut,
13 VideoEditorTaskIntro,
14 VideoEditorTaskOutro,
15 VideoEditorTaskWatermark,
16 VideoState
17} from '@shared/models'
18import { asyncMiddleware, authenticate, videosEditorAddEditionValidator } from '../../../middlewares'
19
20const editorRouter = express.Router()
21
22const tasksFiles = createAnyReqFiles(
23 MIMETYPES.VIDEO.MIMETYPE_EXT,
24 CONFIG.STORAGE.TMP_DIR,
25 (req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
26 const body = req.body as VideoEditorCreateEdition
27
28 // Fetch array element
29 const matches = file.fieldname.match(/tasks\[(\d+)\]/)
30 if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
31
32 const indice = parseInt(matches[1])
33 const task = body.tasks[indice]
34
35 if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
36
37 if (
38 [ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
39 file.fieldname === buildTaskFileFieldname(indice)
40 ) {
41 return cb(null, true)
42 }
43
44 return cb(null, false)
45 }
46)
47
48editorRouter.post('/:videoId/editor/edit',
49 authenticate,
50 tasksFiles,
51 asyncMiddleware(videosEditorAddEditionValidator),
52 asyncMiddleware(createEditionTasks)
53)
54
55// ---------------------------------------------------------------------------
56
57export {
58 editorRouter
59}
60
61// ---------------------------------------------------------------------------
62
63async function createEditionTasks (req: express.Request, res: express.Response) {
64 const files = req.files as Express.Multer.File[]
65 const body = req.body as VideoEditorCreateEdition
66 const video = res.locals.videoAll
67
68 video.state = VideoState.TO_EDIT
69 await video.save()
70
71 const payload = {
72 videoUUID: video.uuid,
73 tasks: body.tasks.map((t, i) => buildTaskPayload(t, i, files))
74 }
75
76 JobQueue.Instance.createJob({ type: 'video-edition', payload })
77
78 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
79}
80
81const taskPayloadBuilders: {
82 [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => VideoEditionTaskPayload
83} = {
84 'add-intro': buildIntroOutroTask,
85 'add-outro': buildIntroOutroTask,
86 'cut': buildCutTask,
87 'add-watermark': buildWatermarkTask
88}
89
90function buildTaskPayload (task: VideoEditorTask, indice: number, files: Express.Multer.File[]): VideoEditionTaskPayload {
91 return taskPayloadBuilders[task.name](task, indice, files)
92}
93
94function buildIntroOutroTask (task: VideoEditorTaskIntro | VideoEditorTaskOutro, indice: number, files: Express.Multer.File[]) {
95 return {
96 name: task.name,
97 options: {
98 file: getTaskFile(files, indice).path
99 }
100 }
101}
102
103function buildCutTask (task: VideoEditorTaskCut) {
104 return {
105 name: task.name,
106 options: {
107 start: task.options.start,
108 end: task.options.end
109 }
110 }
111}
112
113function buildWatermarkTask (task: VideoEditorTaskWatermark, indice: number, files: Express.Multer.File[]) {
114 return {
115 name: task.name,
116 options: {
117 file: getTaskFile(files, indice).path
118 }
119 }
120}
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'
35import { blacklistRouter } from './blacklist' 35import { blacklistRouter } from './blacklist'
36import { videoCaptionsRouter } from './captions' 36import { videoCaptionsRouter } from './captions'
37import { videoCommentRouter } from './comment' 37import { videoCommentRouter } from './comment'
38import { editorRouter } from './editor'
38import { filesRouter } from './files' 39import { filesRouter } from './files'
39import { videoImportsRouter } from './import' 40import { videoImportsRouter } from './import'
40import { liveRouter } from './live' 41import { liveRouter } from './live'
@@ -51,6 +52,7 @@ const videosRouter = express.Router()
51videosRouter.use('/', blacklistRouter) 52videosRouter.use('/', blacklistRouter)
52videosRouter.use('/', rateVideoRouter) 53videosRouter.use('/', rateVideoRouter)
53videosRouter.use('/', videoCommentRouter) 54videosRouter.use('/', videoCommentRouter)
55videosRouter.use('/', editorRouter)
54videosRouter.use('/', videoCaptionsRouter) 56videosRouter.use('/', videoCaptionsRouter)
55videosRouter.use('/', videoImportsRouter) 57videosRouter.use('/', videoImportsRouter)
56videosRouter.use('/', ownershipVideoRouter) 58videosRouter.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 @@
1import express from 'express' 1import express from 'express'
2import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils' 2import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
3import { logger, loggerTagsFactory } from '@server/helpers/logger' 3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { addTranscodingJob } from '@server/lib/video' 4import { addTranscodingJob } from '@server/lib/video'
5import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models' 5import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
@@ -29,7 +29,7 @@ async function createTranscoding (req: express.Request, res: express.Response) {
29 29
30 const body: VideoTranscodingCreate = req.body 30 const body: VideoTranscodingCreate = req.body
31 31
32 const { resolution: maxResolution, isPortraitMode, audioStream } = await video.getMaxQualityFileInfo() 32 const { resolution: maxResolution, isPortraitMode, audioStream } = await video.probeMaxQualityFile()
33 const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ]) 33 const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
34 34
35 video.state = VideoState.TO_TRANSCODE 35 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
24import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 24import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
25import { retryTransactionWrapper } from '../../../helpers/database-utils' 25import { retryTransactionWrapper } from '../../../helpers/database-utils'
26import { createReqFiles } from '../../../helpers/express-utils' 26import { createReqFiles } from '../../../helpers/express-utils'
27import { ffprobePromise, getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 27import { ffprobePromise, buildFileMetadata, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
28import { logger, loggerTagsFactory } from '../../../helpers/logger' 28import { logger, loggerTagsFactory } from '../../../helpers/logger'
29import { CONFIG } from '../../../initializers/config' 29import { CONFIG } from '../../../initializers/config'
30import { MIMETYPES } from '../../../initializers/constants' 30import { MIMETYPES } from '../../../initializers/constants'
@@ -246,7 +246,7 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
246 extname: getLowercaseExtension(videoPhysicalFile.filename), 246 extname: getLowercaseExtension(videoPhysicalFile.filename),
247 size: videoPhysicalFile.size, 247 size: videoPhysicalFile.size,
248 videoStreamingPlaylistId: null, 248 videoStreamingPlaylistId: null,
249 metadata: await getMetadataFromFile(videoPhysicalFile.path) 249 metadata: await buildFileMetadata(videoPhysicalFile.path)
250 }) 250 })
251 251
252 const probe = await ffprobePromise(videoPhysicalFile.path) 252 const probe = await ffprobePromise(videoPhysicalFile.path)
@@ -254,8 +254,8 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
254 if (await isAudioFile(videoPhysicalFile.path, probe)) { 254 if (await isAudioFile(videoPhysicalFile.path, probe)) {
255 videoFile.resolution = VideoResolution.H_NOVIDEO 255 videoFile.resolution = VideoResolution.H_NOVIDEO
256 } else { 256 } else {
257 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path, probe) 257 videoFile.fps = await getVideoStreamFPS(videoPhysicalFile.path, probe)
258 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path, probe)).resolution 258 videoFile.resolution = (await getVideoStreamDimensionsInfo(videoPhysicalFile.path, probe)).resolution
259 } 259 }
260 260
261 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname) 261 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 @@
1 1
2import { UploadFilesForCheck } from 'express'
2import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 3import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
3import { isFileValid } from './misc' 4import { isFileValid } from './misc'
4 5
@@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
6 .map(v => v.replace('.', '')) 7 .map(v => v.replace('.', ''))
7 .join('|') 8 .join('|')
8const imageMimeTypesRegex = `image/(${imageMimeTypes})` 9const imageMimeTypesRegex = `image/(${imageMimeTypes})`
9function isActorImageFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], fieldname: string) { 10
10 return isFileValid(files, imageMimeTypesRegex, fieldname, CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max) 11function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
12 return isFileValid({
13 files,
14 mimeTypeRegex: imageMimeTypesRegex,
15 field: fieldname,
16 maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
17 })
11} 18}
12 19
13// --------------------------------------------------------------------------- 20// ---------------------------------------------------------------------------
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) {
61 61
62// --------------------------------------------------------------------------- 62// ---------------------------------------------------------------------------
63 63
64function isFileFieldValid ( 64function isFileValid (options: {
65 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 65 files: UploadFilesForCheck
66 field: string,
67 optional = false
68) {
69 // Should have files
70 if (!files) return optional
71 if (isArray(files)) return optional
72 66
73 // Should have a file 67 maxSize: number | null
74 const fileArray = files[field] 68 mimeTypeRegex: string | null
75 if (!fileArray || fileArray.length === 0) {
76 return optional
77 }
78 69
79 // The file should exist 70 field?: string
80 const file = fileArray[0]
81 if (!file || !file.originalname) return false
82 return file
83}
84 71
85function isFileMimeTypeValid ( 72 optional?: boolean // Default false
86 files: UploadFilesForCheck, 73}) {
87 mimeTypeRegex: string, 74 const { files, mimeTypeRegex, field, maxSize, optional = false } = options
88 field: string,
89 optional = false
90) {
91 // Should have files
92 if (!files) return optional
93 if (isArray(files)) return optional
94 75
95 // Should have a file
96 const fileArray = files[field]
97 if (!fileArray || fileArray.length === 0) {
98 return optional
99 }
100
101 // The file should exist
102 const file = fileArray[0]
103 if (!file || !file.originalname) return false
104
105 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
106}
107
108function isFileValid (
109 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
110 mimeTypeRegex: string,
111 field: string,
112 maxSize: number | null,
113 optional = false
114) {
115 // Should have files 76 // Should have files
116 if (!files) return optional 77 if (!files) return optional
117 if (isArray(files)) return optional
118 78
119 // Should have a file 79 const fileArray = isArray(files)
120 const fileArray = files[field] 80 ? files
121 if (!fileArray || fileArray.length === 0) { 81 : files[field]
82
83 if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
122 return optional 84 return optional
123 } 85 }
124 86
125 // The file should exist 87 // The file exists
126 const file = fileArray[0] 88 const file = fileArray[0]
127 if (!file || !file.originalname) return false 89 if (!file || !file.originalname) return false
128 90
129 // Check size 91 // Check size
130 if ((maxSize !== null) && file.size > maxSize) return false 92 if ((maxSize !== null) && file.size > maxSize) return false
131 93
132 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype) 94 if (mimeTypeRegex === null) return true
95
96 return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
97}
98
99function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
100 return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
133} 101}
134 102
135// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
@@ -204,7 +172,6 @@ export {
204 areUUIDsValid, 172 areUUIDsValid,
205 toArray, 173 toArray,
206 toIntArray, 174 toIntArray,
207 isFileFieldValid, 175 isFileValid,
208 isFileMimeTypeValid, 176 checkMimetypeRegex
209 isFileValid
210} 177}
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 @@
1import { getFileSize } from '@shared/extra-utils' 1import { UploadFilesForCheck } from 'express'
2import { readFile } from 'fs-extra' 2import { readFile } from 'fs-extra'
3import { getFileSize } from '@shared/extra-utils'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
5 6
@@ -11,8 +12,13 @@ const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT
11 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
12 .map(m => `(${m})`) 13 .map(m => `(${m})`)
13 .join('|') 14 .join('|')
14function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { 15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
15 return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max) 16 return isFileValid({
17 files,
18 mimeTypeRegex: videoCaptionTypesRegex,
19 field,
20 maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
21 })
16} 22}
17 23
18async function isVTTFileValid (filePath: string) { 24async 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 @@
1import validator from 'validator'
2import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
3import { buildTaskFileFieldname } from '@server/lib/video-editor'
4import { VideoEditorTask } from '@shared/models'
5import { isArray } from './misc'
6import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos'
7
8function isValidEditorTasksArray (tasks: any) {
9 if (!isArray(tasks)) return false
10
11 return tasks.length >= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.min &&
12 tasks.length <= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.max
13}
14
15function isEditorCutTaskValid (task: VideoEditorTask) {
16 if (task.name !== 'cut') return false
17 if (!task.options) return false
18
19 const { start, end } = task.options
20 if (!start && !end) return false
21
22 if (start && !validator.isInt(start + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
23 if (end && !validator.isInt(end + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
24
25 if (!start || !end) return true
26
27 return parseInt(start + '') < parseInt(end + '')
28}
29
30function isEditorTaskAddIntroOutroValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
31 const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
32
33 return (task.name === 'add-intro' || task.name === 'add-outro') &&
34 file && isVideoFileMimeTypeValid([ file ], null)
35}
36
37function isEditorTaskAddWatermarkValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
38 const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
39
40 return task.name === 'add-watermark' &&
41 file && isVideoImageValid([ file ], null, true)
42}
43
44// ---------------------------------------------------------------------------
45
46export {
47 isValidEditorTasksArray,
48
49 isEditorCutTaskValid,
50 isEditorTaskAddIntroOutroValid,
51 isEditorTaskAddWatermarkValid
52}
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 @@
1import 'multer' 1import 'multer'
2import { UploadFilesForCheck } from 'express'
2import validator from 'validator' 3import validator from 'validator'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' 4import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 5import { exists, isFileValid } from './misc'
@@ -25,8 +26,14 @@ const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
25 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
26 .map(m => `(${m})`) 27 .map(m => `(${m})`)
27 .join('|') 28 .join('|')
28function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 29function isVideoImportTorrentFile (files: UploadFilesForCheck) {
29 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true) 30 return isFileValid({
31 files,
32 mimeTypeRegex: videoTorrentImportRegex,
33 field: 'torrentfile',
34 maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
35 optional: true
36 })
30} 37}
31 38
32// --------------------------------------------------------------------------- 39// ---------------------------------------------------------------------------
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 {
13 VIDEO_RATE_TYPES, 13 VIDEO_RATE_TYPES,
14 VIDEO_STATES 14 VIDEO_STATES
15} from '../../initializers/constants' 15} from '../../initializers/constants'
16import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc' 16import { exists, isArray, isDateValid, isFileValid } from './misc'
17 17
18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
19 19
@@ -66,7 +66,7 @@ function isVideoTagValid (tag: string) {
66 return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG) 66 return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
67} 67}
68 68
69function isVideoTagsValid (tags: string[]) { 69function areVideoTagsValid (tags: string[]) {
70 return tags === null || ( 70 return tags === null || (
71 isArray(tags) && 71 isArray(tags) &&
72 validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) && 72 validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) &&
@@ -86,8 +86,13 @@ function isVideoFileExtnameValid (value: string) {
86 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined) 86 return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
87} 87}
88 88
89function isVideoFileMimeTypeValid (files: UploadFilesForCheck) { 89function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
90 return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile') 90 return isFileValid({
91 files,
92 mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
93 field,
94 maxSize: null
95 })
91} 96}
92 97
93const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME 98const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
95 .join('|') 100 .join('|')
96const videoImageTypesRegex = `image/(${videoImageTypes})` 101const videoImageTypesRegex = `image/(${videoImageTypes})`
97 102
98function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) { 103function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
99 return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true) 104 return isFileValid({
105 files,
106 mimeTypeRegex: videoImageTypesRegex,
107 field,
108 maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
109 optional
110 })
100} 111}
101 112
102function isVideoPrivacyValid (value: number) { 113function isVideoPrivacyValid (value: number) {
@@ -144,7 +155,7 @@ export {
144 isVideoDescriptionValid, 155 isVideoDescriptionValid,
145 isVideoFileInfoHashValid, 156 isVideoFileInfoHashValid,
146 isVideoNameValid, 157 isVideoNameValid,
147 isVideoTagsValid, 158 areVideoTagsValid,
148 isVideoFPSResolutionValid, 159 isVideoFPSResolutionValid,
149 isScheduleVideoUpdatePrivacyValid, 160 isScheduleVideoUpdatePrivacyValid,
150 isVideoOriginallyPublishedAtValid, 161 isVideoOriginallyPublishedAtValid,
@@ -160,7 +171,7 @@ export {
160 isVideoPrivacyValid, 171 isVideoPrivacyValid,
161 isVideoFileResolutionValid, 172 isVideoFileResolutionValid,
162 isVideoFileSizeValid, 173 isVideoFileSizeValid,
163 isVideoImage, 174 isVideoImageValid,
164 isVideoSupportValid, 175 isVideoSupportValid,
165 isVideoFilterValid 176 isVideoFilterValid
166} 177}
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 @@
1import express, { RequestHandler } from 'express' 1import express, { RequestHandler } from 'express'
2import multer, { diskStorage } from 'multer' 2import multer, { diskStorage } from 'multer'
3import { getLowercaseExtension } from '@shared/core-utils'
3import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 4import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
4import { CONFIG } from '../initializers/config' 5import { CONFIG } from '../initializers/config'
5import { REMOTE_SCHEME } from '../initializers/constants' 6import { REMOTE_SCHEME } from '../initializers/constants'
6import { getLowercaseExtension } from '@shared/core-utils'
7import { isArray } from './custom-validators/misc' 7import { isArray } from './custom-validators/misc'
8import { logger } from './logger' 8import { logger } from './logger'
9import { deleteFileAndCatch, generateRandomString } from './utils' 9import { deleteFileAndCatch, generateRandomString } from './utils'
@@ -75,29 +75,8 @@ function createReqFiles (
75 cb(null, destinations[file.fieldname]) 75 cb(null, destinations[file.fieldname])
76 }, 76 },
77 77
78 filename: async (req, file, cb) => { 78 filename: (req, file, cb) => {
79 let extension: string 79 return generateReqFilename(file, mimeTypes, cb)
80 const fileExtension = getLowercaseExtension(file.originalname)
81 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
82
83 // Take the file extension if we don't understand the mime type
84 if (!extensionFromMimetype) {
85 extension = fileExtension
86 } else {
87 // Take the first available extension for this mimetype
88 extension = extensionFromMimetype
89 }
90
91 let randomString = ''
92
93 try {
94 randomString = await generateRandomString(16)
95 } catch (err) {
96 logger.error('Cannot generate random string for file name.', { err })
97 randomString = 'fake-random-string'
98 }
99
100 cb(null, randomString + extension)
101 } 80 }
102 }) 81 })
103 82
@@ -112,6 +91,24 @@ function createReqFiles (
112 return multer({ storage }).fields(fields) 91 return multer({ storage }).fields(fields)
113} 92}
114 93
94function createAnyReqFiles (
95 mimeTypes: { [id: string]: string | string[] },
96 destinationDirectory: string,
97 fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void
98): RequestHandler {
99 const storage = diskStorage({
100 destination: (req, file, cb) => {
101 cb(null, destinationDirectory)
102 },
103
104 filename: (req, file, cb) => {
105 return generateReqFilename(file, mimeTypes, cb)
106 }
107 })
108
109 return multer({ storage, fileFilter }).any()
110}
111
115function isUserAbleToSearchRemoteURI (res: express.Response) { 112function isUserAbleToSearchRemoteURI (res: express.Response) {
116 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined 113 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
117 114
@@ -128,9 +125,41 @@ function getCountVideos (req: express.Request) {
128export { 125export {
129 buildNSFWFilter, 126 buildNSFWFilter,
130 getHostWithPort, 127 getHostWithPort,
128 createAnyReqFiles,
131 isUserAbleToSearchRemoteURI, 129 isUserAbleToSearchRemoteURI,
132 badRequest, 130 badRequest,
133 createReqFiles, 131 createReqFiles,
134 cleanUpReqFiles, 132 cleanUpReqFiles,
135 getCountVideos 133 getCountVideos
136} 134}
135
136// ---------------------------------------------------------------------------
137
138async function generateReqFilename (
139 file: Express.Multer.File,
140 mimeTypes: { [id: string]: string | string[] },
141 cb: (err: Error, name: string) => void
142) {
143 let extension: string
144 const fileExtension = getLowercaseExtension(file.originalname)
145 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
146
147 // Take the file extension if we don't understand the mime type
148 if (!extensionFromMimetype) {
149 extension = fileExtension
150 } else {
151 // Take the first available extension for this mimetype
152 extension = extensionFromMimetype
153 }
154
155 let randomString = ''
156
157 try {
158 randomString = await generateRandomString(16)
159 } catch (err) {
160 logger.error('Cannot generate random string for file name.', { err })
161 randomString = 'fake-random-string'
162 }
163
164 cb(null, randomString + extension)
165}
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 @@
1import { Job } from 'bull'
2import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
3import { readFile, remove, writeFile } from 'fs-extra'
4import { dirname, join } from 'path'
5import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
6import { pick } from '@shared/core-utils'
7import {
8 AvailableEncoders,
9 EncoderOptions,
10 EncoderOptionsBuilder,
11 EncoderOptionsBuilderParams,
12 EncoderProfile,
13 VideoResolution
14} from '../../shared/models/videos'
15import { CONFIG } from '../initializers/config'
16import { execPromise, promisify0 } from './core-utils'
17import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
18import { processImage } from './image-utils'
19import { logger, loggerTagsFactory } from './logger'
20
21const lTags = loggerTagsFactory('ffmpeg')
22
23/**
24 *
25 * Functions that run transcoding/muxing ffmpeg processes
26 * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
27 *
28 */
29
30// ---------------------------------------------------------------------------
31// Encoder options
32// ---------------------------------------------------------------------------
33
34type StreamType = 'audio' | 'video'
35
36// ---------------------------------------------------------------------------
37// Encoders support
38// ---------------------------------------------------------------------------
39
40// Detect supported encoders by ffmpeg
41let supportedEncoders: Map<string, boolean>
42async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
43 if (supportedEncoders !== undefined) {
44 return supportedEncoders
45 }
46
47 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
48 const availableFFmpegEncoders = await getAvailableEncodersPromise()
49
50 const searchEncoders = new Set<string>()
51 for (const type of [ 'live', 'vod' ]) {
52 for (const streamType of [ 'audio', 'video' ]) {
53 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
54 searchEncoders.add(encoder)
55 }
56 }
57 }
58
59 supportedEncoders = new Map<string, boolean>()
60
61 for (const searchEncoder of searchEncoders) {
62 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
63 }
64
65 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
66
67 return supportedEncoders
68}
69
70function resetSupportedEncoders () {
71 supportedEncoders = undefined
72}
73
74// ---------------------------------------------------------------------------
75// Image manipulation
76// ---------------------------------------------------------------------------
77
78function convertWebPToJPG (path: string, destination: string): Promise<void> {
79 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
80 .output(destination)
81
82 return runCommand({ command, silent: true })
83}
84
85function processGIF (
86 path: string,
87 destination: string,
88 newSize: { width: number, height: number }
89): Promise<void> {
90 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
91 .fps(20)
92 .size(`${newSize.width}x${newSize.height}`)
93 .output(destination)
94
95 return runCommand({ command })
96}
97
98async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
99 const pendingImageName = 'pending-' + imageName
100
101 const options = {
102 filename: pendingImageName,
103 count: 1,
104 folder
105 }
106
107 const pendingImagePath = join(folder, pendingImageName)
108
109 try {
110 await new Promise<string>((res, rej) => {
111 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
112 .on('error', rej)
113 .on('end', () => res(imageName))
114 .thumbnail(options)
115 })
116
117 const destination = join(folder, imageName)
118 await processImage(pendingImagePath, destination, size)
119 } catch (err) {
120 logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
121
122 try {
123 await remove(pendingImagePath)
124 } catch (err) {
125 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
126 }
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Transcode meta function
132// ---------------------------------------------------------------------------
133
134type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
135
136interface BaseTranscodeOptions {
137 type: TranscodeOptionsType
138
139 inputPath: string
140 outputPath: string
141
142 availableEncoders: AvailableEncoders
143 profile: string
144
145 resolution: number
146
147 isPortraitMode?: boolean
148
149 job?: Job
150}
151
152interface HLSTranscodeOptions extends BaseTranscodeOptions {
153 type: 'hls'
154 copyCodecs: boolean
155 hlsPlaylist: {
156 videoFilename: string
157 }
158}
159
160interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
161 type: 'hls-from-ts'
162
163 isAAC: boolean
164
165 hlsPlaylist: {
166 videoFilename: string
167 }
168}
169
170interface QuickTranscodeOptions extends BaseTranscodeOptions {
171 type: 'quick-transcode'
172}
173
174interface VideoTranscodeOptions extends BaseTranscodeOptions {
175 type: 'video'
176}
177
178interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
179 type: 'merge-audio'
180 audioPath: string
181}
182
183interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
184 type: 'only-audio'
185}
186
187type TranscodeOptions =
188 HLSTranscodeOptions
189 | HLSFromTSTranscodeOptions
190 | VideoTranscodeOptions
191 | MergeAudioTranscodeOptions
192 | OnlyAudioTranscodeOptions
193 | QuickTranscodeOptions
194
195const builders: {
196 [ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand
197} = {
198 'quick-transcode': buildQuickTranscodeCommand,
199 'hls': buildHLSVODCommand,
200 'hls-from-ts': buildHLSVODFromTSCommand,
201 'merge-audio': buildAudioMergeCommand,
202 'only-audio': buildOnlyAudioCommand,
203 'video': buildx264VODCommand
204}
205
206async function transcode (options: TranscodeOptions) {
207 logger.debug('Will run transcode.', { options, ...lTags() })
208
209 let command = getFFmpeg(options.inputPath, 'vod')
210 .output(options.outputPath)
211
212 command = await builders[options.type](command, options)
213
214 await runCommand({ command, job: options.job })
215
216 await fixHLSPlaylistIfNeeded(options)
217}
218
219// ---------------------------------------------------------------------------
220// Live muxing/transcoding functions
221// ---------------------------------------------------------------------------
222
223async function getLiveTranscodingCommand (options: {
224 inputUrl: string
225
226 outPath: string
227 masterPlaylistName: string
228
229 resolutions: number[]
230
231 // Input information
232 fps: number
233 bitrate: number
234 ratio: number
235
236 availableEncoders: AvailableEncoders
237 profile: string
238}) {
239 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
240
241 const command = getFFmpeg(inputUrl, 'live')
242
243 const varStreamMap: string[] = []
244
245 const complexFilter: FilterSpecification[] = [
246 {
247 inputs: '[v:0]',
248 filter: 'split',
249 options: resolutions.length,
250 outputs: resolutions.map(r => `vtemp${r}`)
251 }
252 ]
253
254 command.outputOption('-sc_threshold 0')
255
256 addDefaultEncoderGlobalParams({ command })
257
258 for (let i = 0; i < resolutions.length; i++) {
259 const resolution = resolutions[i]
260 const resolutionFPS = computeFPS(fps, resolution)
261
262 const baseEncoderBuilderParams = {
263 input: inputUrl,
264
265 availableEncoders,
266 profile,
267
268 inputBitrate: bitrate,
269 inputRatio: ratio,
270
271 resolution,
272 fps: resolutionFPS,
273
274 streamNum: i,
275 videoType: 'live' as 'live'
276 }
277
278 {
279 const streamType: StreamType = 'video'
280 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
281 if (!builderResult) {
282 throw new Error('No available live video encoder found')
283 }
284
285 command.outputOption(`-map [vout${resolution}]`)
286
287 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
288
289 logger.debug(
290 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
291 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
292 )
293
294 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
295 applyEncoderOptions(command, builderResult.result)
296
297 complexFilter.push({
298 inputs: `vtemp${resolution}`,
299 filter: getScaleFilter(builderResult.result),
300 options: `w=-2:h=${resolution}`,
301 outputs: `vout${resolution}`
302 })
303 }
304
305 {
306 const streamType: StreamType = 'audio'
307 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
308 if (!builderResult) {
309 throw new Error('No available live audio encoder found')
310 }
311
312 command.outputOption('-map a:0')
313
314 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
315
316 logger.debug(
317 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
318 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
319 )
320
321 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
322 applyEncoderOptions(command, builderResult.result)
323 }
324
325 varStreamMap.push(`v:${i},a:${i}`)
326 }
327
328 command.complexFilter(complexFilter)
329
330 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
331
332 command.outputOption('-var_stream_map', varStreamMap.join(' '))
333
334 return command
335}
336
337function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
338 const command = getFFmpeg(inputUrl, 'live')
339
340 command.outputOption('-c:v copy')
341 command.outputOption('-c:a copy')
342 command.outputOption('-map 0:a?')
343 command.outputOption('-map 0:v?')
344
345 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
346
347 return command
348}
349
350function buildStreamSuffix (base: string, streamNum?: number) {
351 if (streamNum !== undefined) {
352 return `${base}:${streamNum}`
353 }
354
355 return base
356}
357
358// ---------------------------------------------------------------------------
359// Default options
360// ---------------------------------------------------------------------------
361
362function addDefaultEncoderGlobalParams (options: {
363 command: FfmpegCommand
364}) {
365 const { command } = options
366
367 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
368 command.outputOption('-max_muxing_queue_size 1024')
369 // strip all metadata
370 .outputOption('-map_metadata -1')
371 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
372 .outputOption('-pix_fmt yuv420p')
373}
374
375function addDefaultEncoderParams (options: {
376 command: FfmpegCommand
377 encoder: 'libx264' | string
378 streamNum?: number
379 fps?: number
380}) {
381 const { command, encoder, fps, streamNum } = options
382
383 if (encoder === 'libx264') {
384 // 3.1 is the minimal resource allocation for our highest supported resolution
385 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
386
387 if (fps) {
388 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
389 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
390 // https://superuser.com/a/908325
391 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
392 }
393 }
394}
395
396function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
397 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
398 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
399 command.outputOption('-hls_flags delete_segments+independent_segments')
400 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
401 command.outputOption('-master_pl_name ' + masterPlaylistName)
402 command.outputOption(`-f hls`)
403
404 command.output(join(outPath, '%v.m3u8'))
405}
406
407// ---------------------------------------------------------------------------
408// Transcode VOD command builders
409// ---------------------------------------------------------------------------
410
411async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) {
412 let fps = await getVideoFileFPS(options.inputPath)
413 fps = computeFPS(fps, options.resolution)
414
415 let scaleFilterValue: string
416
417 if (options.resolution !== undefined) {
418 scaleFilterValue = options.isPortraitMode === true
419 ? `w=${options.resolution}:h=-2`
420 : `w=-2:h=${options.resolution}`
421 }
422
423 command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue })
424
425 return command
426}
427
428async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
429 command = command.loop(undefined)
430
431 const scaleFilterValue = getScaleCleanerValue()
432 command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
433
434 command.outputOption('-preset:v veryfast')
435
436 command = command.input(options.audioPath)
437 .outputOption('-tune stillimage')
438 .outputOption('-shortest')
439
440 return command
441}
442
443function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
444 command = presetOnlyAudio(command)
445
446 return command
447}
448
449function buildQuickTranscodeCommand (command: FfmpegCommand) {
450 command = presetCopy(command)
451
452 command = command.outputOption('-map_metadata -1') // strip all metadata
453 .outputOption('-movflags faststart')
454
455 return command
456}
457
458function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
459 return command.outputOption('-hls_time 4')
460 .outputOption('-hls_list_size 0')
461 .outputOption('-hls_playlist_type vod')
462 .outputOption('-hls_segment_filename ' + outputPath)
463 .outputOption('-hls_segment_type fmp4')
464 .outputOption('-f hls')
465 .outputOption('-hls_flags single_file')
466}
467
468async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
469 const videoPath = getHLSVideoPath(options)
470
471 if (options.copyCodecs) command = presetCopy(command)
472 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
473 else command = await buildx264VODCommand(command, options)
474
475 addCommonHLSVODCommandOptions(command, videoPath)
476
477 return command
478}
479
480function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
481 const videoPath = getHLSVideoPath(options)
482
483 command.outputOption('-c copy')
484
485 if (options.isAAC) {
486 // Required for example when copying an AAC stream from an MPEG-TS
487 // Since it's a bitstream filter, we don't need to reencode the audio
488 command.outputOption('-bsf:a aac_adtstoasc')
489 }
490
491 addCommonHLSVODCommandOptions(command, videoPath)
492
493 return command
494}
495
496async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
497 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
498
499 const fileContent = await readFile(options.outputPath)
500
501 const videoFileName = options.hlsPlaylist.videoFilename
502 const videoFilePath = getHLSVideoPath(options)
503
504 // Fix wrong mapping with some ffmpeg versions
505 const newContent = fileContent.toString()
506 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
507
508 await writeFile(options.outputPath, newContent)
509}
510
511function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
512 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
513}
514
515// ---------------------------------------------------------------------------
516// Transcoding presets
517// ---------------------------------------------------------------------------
518
519// Run encoder builder depending on available encoders
520// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
521// If the default one does not exist, check the next encoder
522async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
523 streamType: 'video' | 'audio'
524 input: string
525
526 availableEncoders: AvailableEncoders
527 profile: string
528
529 videoType: 'vod' | 'live'
530}) {
531 const { availableEncoders, profile, streamType, videoType } = options
532
533 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
534 const encoders = availableEncoders.available[videoType]
535
536 for (const encoder of encodersToTry) {
537 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
538 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
539 continue
540 }
541
542 if (!encoders[encoder]) {
543 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
544 continue
545 }
546
547 // An object containing available profiles for this encoder
548 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
549 let builder = builderProfiles[profile]
550
551 if (!builder) {
552 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
553 builder = builderProfiles.default
554
555 if (!builder) {
556 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
557 continue
558 }
559 }
560
561 const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]))
562
563 return {
564 result,
565
566 // If we don't have output options, then copy the input stream
567 encoder: result.copy === true
568 ? 'copy'
569 : encoder
570 }
571 }
572
573 return null
574}
575
576async function presetVideo (options: {
577 command: FfmpegCommand
578 input: string
579 transcodeOptions: TranscodeOptions
580 fps?: number
581 scaleFilterValue?: string
582}) {
583 const { command, input, transcodeOptions, fps, scaleFilterValue } = options
584
585 let localCommand = command
586 .format('mp4')
587 .outputOption('-movflags faststart')
588
589 addDefaultEncoderGlobalParams({ command })
590
591 const probe = await ffprobePromise(input)
592
593 // Audio encoder
594 const parsedAudio = await getAudioStream(input, probe)
595 const bitrate = await getVideoFileBitrate(input, probe)
596 const { ratio } = await getVideoFileResolution(input, probe)
597
598 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
599
600 if (!parsedAudio.audioStream) {
601 localCommand = localCommand.noAudio()
602 streamsToProcess = [ 'video' ]
603 }
604
605 for (const streamType of streamsToProcess) {
606 const { profile, resolution, availableEncoders } = transcodeOptions
607
608 const builderResult = await getEncoderBuilderResult({
609 streamType,
610 input,
611 resolution,
612 availableEncoders,
613 profile,
614 fps,
615 inputBitrate: bitrate,
616 inputRatio: ratio,
617 videoType: 'vod' as 'vod'
618 })
619
620 if (!builderResult) {
621 throw new Error('No available encoder found for stream ' + streamType)
622 }
623
624 logger.debug(
625 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
626 builderResult.encoder, streamType, input, profile,
627 { builderResult, resolution, fps, ...lTags() }
628 )
629
630 if (streamType === 'video') {
631 localCommand.videoCodec(builderResult.encoder)
632
633 if (scaleFilterValue) {
634 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
635 }
636 } else if (streamType === 'audio') {
637 localCommand.audioCodec(builderResult.encoder)
638 }
639
640 applyEncoderOptions(localCommand, builderResult.result)
641 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
642 }
643
644 return localCommand
645}
646
647function presetCopy (command: FfmpegCommand): FfmpegCommand {
648 return command
649 .format('mp4')
650 .videoCodec('copy')
651 .audioCodec('copy')
652}
653
654function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
655 return command
656 .format('mp4')
657 .audioCodec('copy')
658 .noVideo()
659}
660
661function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
662 return command
663 .inputOptions(options.inputOptions ?? [])
664 .outputOptions(options.outputOptions ?? [])
665}
666
667function getScaleFilter (options: EncoderOptions): string {
668 if (options.scaleFilter) return options.scaleFilter.name
669
670 return 'scale'
671}
672
673// ---------------------------------------------------------------------------
674// Utils
675// ---------------------------------------------------------------------------
676
677function getFFmpeg (input: string, type: 'live' | 'vod') {
678 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
679 const command = ffmpeg(input, {
680 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
681 cwd: CONFIG.STORAGE.TMP_DIR
682 })
683
684 const threads = type === 'live'
685 ? CONFIG.LIVE.TRANSCODING.THREADS
686 : CONFIG.TRANSCODING.THREADS
687
688 if (threads > 0) {
689 // If we don't set any threads ffmpeg will chose automatically
690 command.outputOption('-threads ' + threads)
691 }
692
693 return command
694}
695
696function getFFmpegVersion () {
697 return new Promise<string>((res, rej) => {
698 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
699 if (err) return rej(err)
700 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
701
702 return execPromise(`${ffmpegPath} -version`)
703 .then(stdout => {
704 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
705 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
706
707 // Fix ffmpeg version that does not include patch version (4.4 for example)
708 let version = parsed[1]
709 if (version.match(/^\d+\.\d+$/)) {
710 version += '.0'
711 }
712
713 return res(version)
714 })
715 .catch(err => rej(err))
716 })
717 })
718}
719
720async function runCommand (options: {
721 command: FfmpegCommand
722 silent?: boolean // false
723 job?: Job
724}) {
725 const { command, silent = false, job } = options
726
727 return new Promise<void>((res, rej) => {
728 let shellCommand: string
729
730 command.on('start', cmdline => { shellCommand = cmdline })
731
732 command.on('error', (err, stdout, stderr) => {
733 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
734
735 rej(err)
736 })
737
738 command.on('end', (stdout, stderr) => {
739 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
740
741 res()
742 })
743
744 if (job) {
745 command.on('progress', progress => {
746 if (!progress.percent) return
747
748 job.progress(Math.round(progress.percent))
749 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
750 })
751 }
752
753 command.run()
754 })
755}
756
757// Avoid "height not divisible by 2" error
758function getScaleCleanerValue () {
759 return 'trunc(iw/2)*2:trunc(ih/2)*2'
760}
761
762// ---------------------------------------------------------------------------
763
764export {
765 getLiveTranscodingCommand,
766 getLiveMuxingCommand,
767 buildStreamSuffix,
768 convertWebPToJPG,
769 processGIF,
770 generateImageFromVideoFile,
771 TranscodeOptions,
772 TranscodeOptionsType,
773 transcode,
774 runCommand,
775 getFFmpegVersion,
776
777 resetSupportedEncoders,
778
779 // builders
780 buildx264VODCommand
781}
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 @@
1import { Job } from 'bull'
2import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
3import { execPromise } from '@server/helpers/core-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config'
6import { FFMPEG_NICE } from '@server/initializers/constants'
7import { EncoderOptions } from '@shared/models'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11type StreamType = 'audio' | 'video'
12
13function getFFmpeg (input: string, type: 'live' | 'vod') {
14 // We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
15 const command = ffmpeg(input, {
16 niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
17 cwd: CONFIG.STORAGE.TMP_DIR
18 })
19
20 const threads = type === 'live'
21 ? CONFIG.LIVE.TRANSCODING.THREADS
22 : CONFIG.TRANSCODING.THREADS
23
24 if (threads > 0) {
25 // If we don't set any threads ffmpeg will chose automatically
26 command.outputOption('-threads ' + threads)
27 }
28
29 return command
30}
31
32function getFFmpegVersion () {
33 return new Promise<string>((res, rej) => {
34 (ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
35 if (err) return rej(err)
36 if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
37
38 return execPromise(`${ffmpegPath} -version`)
39 .then(stdout => {
40 const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
41 if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
42
43 // Fix ffmpeg version that does not include patch version (4.4 for example)
44 let version = parsed[1]
45 if (version.match(/^\d+\.\d+$/)) {
46 version += '.0'
47 }
48
49 return res(version)
50 })
51 .catch(err => rej(err))
52 })
53 })
54}
55
56async function runCommand (options: {
57 command: FfmpegCommand
58 silent?: boolean // false by default
59 job?: Job
60}) {
61 const { command, silent = false, job } = options
62
63 return new Promise<void>((res, rej) => {
64 let shellCommand: string
65
66 command.on('start', cmdline => { shellCommand = cmdline })
67
68 command.on('error', (err, stdout, stderr) => {
69 if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
70
71 rej(err)
72 })
73
74 command.on('end', (stdout, stderr) => {
75 logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
76
77 res()
78 })
79
80 if (job) {
81 command.on('progress', progress => {
82 if (!progress.percent) return
83
84 job.progress(Math.round(progress.percent))
85 .catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
86 })
87 }
88
89 command.run()
90 })
91}
92
93function buildStreamSuffix (base: string, streamNum?: number) {
94 if (streamNum !== undefined) {
95 return `${base}:${streamNum}`
96 }
97
98 return base
99}
100
101function getScaleFilter (options: EncoderOptions): string {
102 if (options.scaleFilter) return options.scaleFilter.name
103
104 return 'scale'
105}
106
107export {
108 getFFmpeg,
109 getFFmpegVersion,
110 runCommand,
111 StreamType,
112 buildStreamSuffix,
113 getScaleFilter
114}
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 @@
1import { FilterSpecification } from 'fluent-ffmpeg'
2import { VIDEO_FILTERS } from '@server/initializers/constants'
3import { AvailableEncoders } from '@shared/models'
4import { logger, loggerTagsFactory } from '../logger'
5import { getFFmpeg, runCommand } from './ffmpeg-commons'
6import { presetCopy, presetVOD } from './ffmpeg-presets'
7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11async function cutVideo (options: {
12 inputPath: string
13 outputPath: string
14 start?: number
15 end?: number
16}) {
17 const { inputPath, outputPath } = options
18
19 logger.debug('Will cut the video.', { options, ...lTags() })
20
21 let command = getFFmpeg(inputPath, 'vod')
22 .output(outputPath)
23
24 command = presetCopy(command)
25
26 if (options.start) command.inputOption('-ss ' + options.start)
27
28 if (options.end) {
29 const endSeeking = options.end - (options.start || 0)
30
31 command.outputOption('-to ' + endSeeking)
32 }
33
34 await runCommand({ command })
35}
36
37async function addWatermark (options: {
38 inputPath: string
39 watermarkPath: string
40 outputPath: string
41
42 availableEncoders: AvailableEncoders
43 profile: string
44}) {
45 const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options
46
47 logger.debug('Will add watermark to the video.', { options, ...lTags() })
48
49 const videoProbe = await ffprobePromise(inputPath)
50 const fps = await getVideoStreamFPS(inputPath, videoProbe)
51 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
52
53 let command = getFFmpeg(inputPath, 'vod')
54 .output(outputPath)
55 command.input(watermarkPath)
56
57 command = await presetVOD({
58 command,
59 input: inputPath,
60 availableEncoders,
61 profile,
62 resolution,
63 fps,
64 canCopyAudio: true,
65 canCopyVideo: false
66 })
67
68 const complexFilter: FilterSpecification[] = [
69 // Scale watermark
70 {
71 inputs: [ '[1]', '[0]' ],
72 filter: 'scale2ref',
73 options: {
74 w: 'oh*mdar',
75 h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}`
76 },
77 outputs: [ '[watermark]', '[video]' ]
78 },
79
80 {
81 inputs: [ '[video]', '[watermark]' ],
82 filter: 'overlay',
83 options: {
84 x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`,
85 y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}`
86 }
87 }
88 ]
89
90 command.complexFilter(complexFilter)
91
92 await runCommand({ command })
93}
94
95async function addIntroOutro (options: {
96 inputPath: string
97 introOutroPath: string
98 outputPath: string
99 type: 'intro' | 'outro'
100
101 availableEncoders: AvailableEncoders
102 profile: string
103}) {
104 const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options
105
106 logger.debug('Will add intro/outro to the video.', { options, ...lTags() })
107
108 const mainProbe = await ffprobePromise(inputPath)
109 const fps = await getVideoStreamFPS(inputPath, mainProbe)
110 const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
111 const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
112
113 const introOutroProbe = await ffprobePromise(introOutroPath)
114 const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
115
116 let command = getFFmpeg(inputPath, 'vod')
117 .output(outputPath)
118
119 command.input(introOutroPath)
120
121 if (!introOutroHasAudio && mainHasAudio) {
122 const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
123
124 command.input('anullsrc')
125 command.withInputFormat('lavfi')
126 command.withInputOption('-t ' + duration)
127 }
128
129 command = await presetVOD({
130 command,
131 input: inputPath,
132 availableEncoders,
133 profile,
134 resolution,
135 fps,
136 canCopyAudio: false,
137 canCopyVideo: false
138 })
139
140 // Add black background to correctly scale intro/outro with padding
141 const complexFilter: FilterSpecification[] = [
142 {
143 inputs: [ '1', '0' ],
144 filter: 'scale2ref',
145 options: {
146 w: 'iw',
147 h: `ih`
148 },
149 outputs: [ 'intro-outro', 'main' ]
150 },
151 {
152 inputs: [ 'intro-outro', 'main' ],
153 filter: 'scale2ref',
154 options: {
155 w: 'iw',
156 h: `ih`
157 },
158 outputs: [ 'to-scale', 'main' ]
159 },
160 {
161 inputs: 'to-scale',
162 filter: 'drawbox',
163 options: {
164 t: 'fill'
165 },
166 outputs: [ 'to-scale-bg' ]
167 },
168 {
169 inputs: [ '1', 'to-scale-bg' ],
170 filter: 'scale2ref',
171 options: {
172 w: 'iw',
173 h: 'ih',
174 force_original_aspect_ratio: 'decrease',
175 flags: 'spline'
176 },
177 outputs: [ 'to-scale', 'to-scale-bg' ]
178 },
179 {
180 inputs: [ 'to-scale-bg', 'to-scale' ],
181 filter: 'overlay',
182 options: {
183 x: '(main_w - overlay_w)/2',
184 y: '(main_h - overlay_h)/2'
185 },
186 outputs: 'intro-outro-resized'
187 }
188 ]
189
190 const concatFilter = {
191 inputs: [],
192 filter: 'concat',
193 options: {
194 n: 2,
195 v: 1,
196 unsafe: 1
197 },
198 outputs: [ 'v' ]
199 }
200
201 const introOutroFilterInputs = [ 'intro-outro-resized' ]
202 const mainFilterInputs = [ 'main' ]
203
204 if (mainHasAudio) {
205 mainFilterInputs.push('0:a')
206
207 if (introOutroHasAudio) {
208 introOutroFilterInputs.push('1:a')
209 } else {
210 // Silent input
211 introOutroFilterInputs.push('2:a')
212 }
213 }
214
215 if (type === 'intro') {
216 concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
217 } else {
218 concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
219 }
220
221 if (mainHasAudio) {
222 concatFilter.options['a'] = 1
223 concatFilter.outputs.push('a')
224
225 command.outputOption('-map [a]')
226 }
227
228 command.outputOption('-map [v]')
229
230 complexFilter.push(concatFilter)
231 command.complexFilter(complexFilter)
232
233 await runCommand({ command })
234}
235
236// ---------------------------------------------------------------------------
237
238export {
239 cutVideo,
240 addIntroOutro,
241 addWatermark
242}
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 @@
1import { getAvailableEncoders } from 'fluent-ffmpeg'
2import { pick } from '@shared/core-utils'
3import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
4import { promisify0 } from '../core-utils'
5import { logger, loggerTagsFactory } from '../logger'
6
7const lTags = loggerTagsFactory('ffmpeg')
8
9// Detect supported encoders by ffmpeg
10let supportedEncoders: Map<string, boolean>
11async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
12 if (supportedEncoders !== undefined) {
13 return supportedEncoders
14 }
15
16 const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
17 const availableFFmpegEncoders = await getAvailableEncodersPromise()
18
19 const searchEncoders = new Set<string>()
20 for (const type of [ 'live', 'vod' ]) {
21 for (const streamType of [ 'audio', 'video' ]) {
22 for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
23 searchEncoders.add(encoder)
24 }
25 }
26 }
27
28 supportedEncoders = new Map<string, boolean>()
29
30 for (const searchEncoder of searchEncoders) {
31 supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
32 }
33
34 logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
35
36 return supportedEncoders
37}
38
39function resetSupportedEncoders () {
40 supportedEncoders = undefined
41}
42
43// Run encoder builder depending on available encoders
44// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
45// If the default one does not exist, check the next encoder
46async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
47 streamType: 'video' | 'audio'
48 input: string
49
50 availableEncoders: AvailableEncoders
51 profile: string
52
53 videoType: 'vod' | 'live'
54}) {
55 const { availableEncoders, profile, streamType, videoType } = options
56
57 const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
58 const encoders = availableEncoders.available[videoType]
59
60 for (const encoder of encodersToTry) {
61 if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
62 logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
63 continue
64 }
65
66 if (!encoders[encoder]) {
67 logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
68 continue
69 }
70
71 // An object containing available profiles for this encoder
72 const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
73 let builder = builderProfiles[profile]
74
75 if (!builder) {
76 logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
77 builder = builderProfiles.default
78
79 if (!builder) {
80 logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
81 continue
82 }
83 }
84
85 const result = await builder(
86 pick(options, [
87 'input',
88 'canCopyAudio',
89 'canCopyVideo',
90 'resolution',
91 'inputBitrate',
92 'fps',
93 'inputRatio',
94 'streamNum'
95 ])
96 )
97
98 return {
99 result,
100
101 // If we don't have output options, then copy the input stream
102 encoder: result.copy === true
103 ? 'copy'
104 : encoder
105 }
106 }
107
108 return null
109}
110
111export {
112 checkFFmpegEncoders,
113 resetSupportedEncoders,
114
115 getEncoderBuilderResult
116}
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 @@
1import ffmpeg from 'fluent-ffmpeg'
2import { FFMPEG_NICE } from '@server/initializers/constants'
3import { runCommand } from './ffmpeg-commons'
4
5function convertWebPToJPG (path: string, destination: string): Promise<void> {
6 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
7 .output(destination)
8
9 return runCommand({ command, silent: true })
10}
11
12function processGIF (
13 path: string,
14 destination: string,
15 newSize: { width: number, height: number }
16): Promise<void> {
17 const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
18 .fps(20)
19 .size(`${newSize.width}x${newSize.height}`)
20 .output(destination)
21
22 return runCommand({ command })
23}
24
25async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) {
26 const pendingImageName = 'pending-' + imageName
27
28 const options = {
29 filename: pendingImageName,
30 count: 1,
31 folder
32 }
33
34 return new Promise<string>((res, rej) => {
35 ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
36 .on('error', rej)
37 .on('end', () => res(imageName))
38 .thumbnail(options)
39 })
40}
41
42export {
43 convertWebPToJPG,
44 processGIF,
45 generateThumbnailFromVideo
46}
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 @@
1import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path'
3import { VIDEO_LIVE } from '@server/initializers/constants'
4import { AvailableEncoders } from '@shared/models'
5import { logger, loggerTagsFactory } from '../logger'
6import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
7import { getEncoderBuilderResult } from './ffmpeg-encoders'
8import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
9import { computeFPS } from './ffprobe-utils'
10
11const lTags = loggerTagsFactory('ffmpeg')
12
13async function getLiveTranscodingCommand (options: {
14 inputUrl: string
15
16 outPath: string
17 masterPlaylistName: string
18
19 resolutions: number[]
20
21 // Input information
22 fps: number
23 bitrate: number
24 ratio: number
25
26 availableEncoders: AvailableEncoders
27 profile: string
28}) {
29 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
30
31 const command = getFFmpeg(inputUrl, 'live')
32
33 const varStreamMap: string[] = []
34
35 const complexFilter: FilterSpecification[] = [
36 {
37 inputs: '[v:0]',
38 filter: 'split',
39 options: resolutions.length,
40 outputs: resolutions.map(r => `vtemp${r}`)
41 }
42 ]
43
44 command.outputOption('-sc_threshold 0')
45
46 addDefaultEncoderGlobalParams(command)
47
48 for (let i = 0; i < resolutions.length; i++) {
49 const resolution = resolutions[i]
50 const resolutionFPS = computeFPS(fps, resolution)
51
52 const baseEncoderBuilderParams = {
53 input: inputUrl,
54
55 availableEncoders,
56 profile,
57
58 canCopyAudio: true,
59 canCopyVideo: true,
60
61 inputBitrate: bitrate,
62 inputRatio: ratio,
63
64 resolution,
65 fps: resolutionFPS,
66
67 streamNum: i,
68 videoType: 'live' as 'live'
69 }
70
71 {
72 const streamType: StreamType = 'video'
73 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
74 if (!builderResult) {
75 throw new Error('No available live video encoder found')
76 }
77
78 command.outputOption(`-map [vout${resolution}]`)
79
80 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
81
82 logger.debug(
83 'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
84 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
85 )
86
87 command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
88 applyEncoderOptions(command, builderResult.result)
89
90 complexFilter.push({
91 inputs: `vtemp${resolution}`,
92 filter: getScaleFilter(builderResult.result),
93 options: `w=-2:h=${resolution}`,
94 outputs: `vout${resolution}`
95 })
96 }
97
98 {
99 const streamType: StreamType = 'audio'
100 const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
101 if (!builderResult) {
102 throw new Error('No available live audio encoder found')
103 }
104
105 command.outputOption('-map a:0')
106
107 addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
108
109 logger.debug(
110 'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
111 { builderResult, fps: resolutionFPS, resolution, ...lTags() }
112 )
113
114 command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
115 applyEncoderOptions(command, builderResult.result)
116 }
117
118 varStreamMap.push(`v:${i},a:${i}`)
119 }
120
121 command.complexFilter(complexFilter)
122
123 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
124
125 command.outputOption('-var_stream_map', varStreamMap.join(' '))
126
127 return command
128}
129
130function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
131 const command = getFFmpeg(inputUrl, 'live')
132
133 command.outputOption('-c:v copy')
134 command.outputOption('-c:a copy')
135 command.outputOption('-map 0:a?')
136 command.outputOption('-map 0:v?')
137
138 addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
139
140 return command
141}
142
143// ---------------------------------------------------------------------------
144
145export {
146 getLiveTranscodingCommand,
147 getLiveMuxingCommand
148}
149
150// ---------------------------------------------------------------------------
151
152function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
153 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
154 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
155 command.outputOption('-hls_flags delete_segments+independent_segments')
156 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
157 command.outputOption('-master_pl_name ' + masterPlaylistName)
158 command.outputOption(`-f hls`)
159
160 command.output(join(outPath, '%v.m3u8'))
161}
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 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { pick } from 'lodash'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { AvailableEncoders, EncoderOptions } from '@shared/models'
5import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
6import { getEncoderBuilderResult } from './ffmpeg-encoders'
7import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
8
9const lTags = loggerTagsFactory('ffmpeg')
10
11// ---------------------------------------------------------------------------
12
13function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
14 // avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
15 command.outputOption('-max_muxing_queue_size 1024')
16 // strip all metadata
17 .outputOption('-map_metadata -1')
18 // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
19 .outputOption('-pix_fmt yuv420p')
20}
21
22function addDefaultEncoderParams (options: {
23 command: FfmpegCommand
24 encoder: 'libx264' | string
25 fps: number
26
27 streamNum?: number
28}) {
29 const { command, encoder, fps, streamNum } = options
30
31 if (encoder === 'libx264') {
32 // 3.1 is the minimal resource allocation for our highest supported resolution
33 command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
34
35 if (fps) {
36 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
37 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
38 // https://superuser.com/a/908325
39 command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
40 }
41 }
42}
43
44// ---------------------------------------------------------------------------
45
46async function presetVOD (options: {
47 command: FfmpegCommand
48 input: string
49
50 availableEncoders: AvailableEncoders
51 profile: string
52
53 canCopyAudio: boolean
54 canCopyVideo: boolean
55
56 resolution: number
57 fps: number
58
59 scaleFilterValue?: string
60}) {
61 const { command, input, profile, resolution, fps, scaleFilterValue } = options
62
63 let localCommand = command
64 .format('mp4')
65 .outputOption('-movflags faststart')
66
67 addDefaultEncoderGlobalParams(command)
68
69 const probe = await ffprobePromise(input)
70
71 // Audio encoder
72 const bitrate = await getVideoStreamBitrate(input, probe)
73 const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
74
75 let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
76
77 if (!await hasAudioStream(input, probe)) {
78 localCommand = localCommand.noAudio()
79 streamsToProcess = [ 'video' ]
80 }
81
82 for (const streamType of streamsToProcess) {
83 const builderResult = await getEncoderBuilderResult({
84 ...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]),
85
86 input,
87 inputBitrate: bitrate,
88 inputRatio: videoStreamDimensions?.ratio || 0,
89
90 profile,
91 resolution,
92 fps,
93 streamType,
94
95 videoType: 'vod' as 'vod'
96 })
97
98 if (!builderResult) {
99 throw new Error('No available encoder found for stream ' + streamType)
100 }
101
102 logger.debug(
103 'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
104 builderResult.encoder, streamType, input, profile,
105 { builderResult, resolution, fps, ...lTags() }
106 )
107
108 if (streamType === 'video') {
109 localCommand.videoCodec(builderResult.encoder)
110
111 if (scaleFilterValue) {
112 localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
113 }
114 } else if (streamType === 'audio') {
115 localCommand.audioCodec(builderResult.encoder)
116 }
117
118 applyEncoderOptions(localCommand, builderResult.result)
119 addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
120 }
121
122 return localCommand
123}
124
125function presetCopy (command: FfmpegCommand): FfmpegCommand {
126 return command
127 .format('mp4')
128 .videoCodec('copy')
129 .audioCodec('copy')
130}
131
132function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
133 return command
134 .format('mp4')
135 .audioCodec('copy')
136 .noVideo()
137}
138
139function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
140 return command
141 .inputOptions(options.inputOptions ?? [])
142 .outputOptions(options.outputOptions ?? [])
143}
144
145// ---------------------------------------------------------------------------
146
147export {
148 presetVOD,
149 presetCopy,
150 presetOnlyAudio,
151
152 addDefaultEncoderGlobalParams,
153 addDefaultEncoderParams,
154
155 applyEncoderOptions
156}
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 @@
1import { Job } from 'bull'
2import { FfmpegCommand } from 'fluent-ffmpeg'
3import { readFile, writeFile } from 'fs-extra'
4import { dirname } from 'path'
5import { pick } from '@shared/core-utils'
6import { AvailableEncoders, VideoResolution } from '@shared/models'
7import { logger, loggerTagsFactory } from '../logger'
8import { getFFmpeg, runCommand } from './ffmpeg-commons'
9import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
10import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
11import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
12
13const lTags = loggerTagsFactory('ffmpeg')
14
15// ---------------------------------------------------------------------------
16
17type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
18
19interface BaseTranscodeVODOptions {
20 type: TranscodeVODOptionsType
21
22 inputPath: string
23 outputPath: string
24
25 availableEncoders: AvailableEncoders
26 profile: string
27
28 resolution: number
29
30 isPortraitMode?: boolean
31
32 job?: Job
33}
34
35interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
36 type: 'hls'
37 copyCodecs: boolean
38 hlsPlaylist: {
39 videoFilename: string
40 }
41}
42
43interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
44 type: 'hls-from-ts'
45
46 isAAC: boolean
47
48 hlsPlaylist: {
49 videoFilename: string
50 }
51}
52
53interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
54 type: 'quick-transcode'
55}
56
57interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
58 type: 'video'
59}
60
61interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
62 type: 'merge-audio'
63 audioPath: string
64}
65
66interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
67 type: 'only-audio'
68}
69
70type TranscodeVODOptions =
71 HLSTranscodeOptions
72 | HLSFromTSTranscodeOptions
73 | VideoTranscodeOptions
74 | MergeAudioTranscodeOptions
75 | OnlyAudioTranscodeOptions
76 | QuickTranscodeOptions
77
78// ---------------------------------------------------------------------------
79
80const builders: {
81 [ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
82} = {
83 'quick-transcode': buildQuickTranscodeCommand,
84 'hls': buildHLSVODCommand,
85 'hls-from-ts': buildHLSVODFromTSCommand,
86 'merge-audio': buildAudioMergeCommand,
87 'only-audio': buildOnlyAudioCommand,
88 'video': buildVODCommand
89}
90
91async function transcodeVOD (options: TranscodeVODOptions) {
92 logger.debug('Will run transcode.', { options, ...lTags() })
93
94 let command = getFFmpeg(options.inputPath, 'vod')
95 .output(options.outputPath)
96
97 command = await builders[options.type](command, options)
98
99 await runCommand({ command, job: options.job })
100
101 await fixHLSPlaylistIfNeeded(options)
102}
103
104// ---------------------------------------------------------------------------
105
106export {
107 transcodeVOD,
108
109 buildVODCommand,
110
111 TranscodeVODOptions,
112 TranscodeVODOptionsType
113}
114
115// ---------------------------------------------------------------------------
116
117async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
118 let fps = await getVideoStreamFPS(options.inputPath)
119 fps = computeFPS(fps, options.resolution)
120
121 let scaleFilterValue: string
122
123 if (options.resolution !== undefined) {
124 scaleFilterValue = options.isPortraitMode === true
125 ? `w=${options.resolution}:h=-2`
126 : `w=-2:h=${options.resolution}`
127 }
128
129 command = await presetVOD({
130 ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
131
132 command,
133 input: options.inputPath,
134 canCopyAudio: true,
135 canCopyVideo: true,
136 fps,
137 scaleFilterValue
138 })
139
140 return command
141}
142
143function buildQuickTranscodeCommand (command: FfmpegCommand) {
144 command = presetCopy(command)
145
146 command = command.outputOption('-map_metadata -1') // strip all metadata
147 .outputOption('-movflags faststart')
148
149 return command
150}
151
152// ---------------------------------------------------------------------------
153// Audio transcoding
154// ---------------------------------------------------------------------------
155
156async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
157 command = command.loop(undefined)
158
159 const scaleFilterValue = getMergeAudioScaleFilterValue()
160 command = await presetVOD({
161 ...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
162
163 command,
164 input: options.audioPath,
165 canCopyAudio: true,
166 canCopyVideo: true,
167 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
168 scaleFilterValue
169 })
170
171 command.outputOption('-preset:v veryfast')
172
173 command = command.input(options.audioPath)
174 .outputOption('-tune stillimage')
175 .outputOption('-shortest')
176
177 return command
178}
179
180function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
181 command = presetOnlyAudio(command)
182
183 return command
184}
185
186// ---------------------------------------------------------------------------
187// HLS transcoding
188// ---------------------------------------------------------------------------
189
190async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
191 const videoPath = getHLSVideoPath(options)
192
193 if (options.copyCodecs) command = presetCopy(command)
194 else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
195 else command = await buildVODCommand(command, options)
196
197 addCommonHLSVODCommandOptions(command, videoPath)
198
199 return command
200}
201
202function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
203 const videoPath = getHLSVideoPath(options)
204
205 command.outputOption('-c copy')
206
207 if (options.isAAC) {
208 // Required for example when copying an AAC stream from an MPEG-TS
209 // Since it's a bitstream filter, we don't need to reencode the audio
210 command.outputOption('-bsf:a aac_adtstoasc')
211 }
212
213 addCommonHLSVODCommandOptions(command, videoPath)
214
215 return command
216}
217
218function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
219 return command.outputOption('-hls_time 4')
220 .outputOption('-hls_list_size 0')
221 .outputOption('-hls_playlist_type vod')
222 .outputOption('-hls_segment_filename ' + outputPath)
223 .outputOption('-hls_segment_type fmp4')
224 .outputOption('-f hls')
225 .outputOption('-hls_flags single_file')
226}
227
228async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
229 if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
230
231 const fileContent = await readFile(options.outputPath)
232
233 const videoFileName = options.hlsPlaylist.videoFilename
234 const videoFilePath = getHLSVideoPath(options)
235
236 // Fix wrong mapping with some ffmpeg versions
237 const newContent = fileContent.toString()
238 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
239
240 await writeFile(options.outputPath, newContent)
241}
242
243// ---------------------------------------------------------------------------
244// Helpers
245// ---------------------------------------------------------------------------
246
247function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
248 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
249}
250
251// Avoid "height not divisible by 2" error
252function getMergeAudioScaleFilterValue () {
253 return 'trunc(iw/2)*2:trunc(ih/2)*2'
254}
diff --git a/server/helpers/ffprobe-utils.ts b/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 @@
1import { FfprobeData } from 'fluent-ffmpeg' 1import { FfprobeData } from 'fluent-ffmpeg'
2import { getMaxBitrate } from '@shared/core-utils' 2import { getMaxBitrate } from '@shared/core-utils'
3import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos'
4import { CONFIG } from '../initializers/config'
5import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
6import { logger } from './logger'
7import { 3import {
8 canDoQuickAudioTranscode,
9 ffprobePromise, 4 ffprobePromise,
10 getDurationFromVideoFile,
11 getAudioStream, 5 getAudioStream,
6 getVideoStreamDuration,
12 getMaxAudioBitrate, 7 getMaxAudioBitrate,
13 getMetadataFromFile, 8 buildFileMetadata,
14 getVideoFileBitrate, 9 getVideoStreamBitrate,
15 getVideoFileFPS, 10 getVideoStreamFPS,
16 getVideoFileResolution, 11 getVideoStream,
17 getVideoStreamFromFile, 12 getVideoStreamDimensionsInfo,
18 getVideoStreamSize 13 hasAudioStream
19} from '@shared/extra-utils/ffprobe' 14} from '@shared/extra-utils/ffprobe'
15import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
16import { CONFIG } from '../../initializers/config'
17import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
18import { logger } from '../logger'
20 19
21/** 20/**
22 * 21 *
@@ -24,9 +23,12 @@ import {
24 * 23 *
25 */ 24 */
26 25
27async function getVideoStreamCodec (path: string) { 26// ---------------------------------------------------------------------------
28 const videoStream = await getVideoStreamFromFile(path) 27// Codecs
28// ---------------------------------------------------------------------------
29 29
30async function getVideoStreamCodec (path: string) {
31 const videoStream = await getVideoStream(path)
30 if (!videoStream) return '' 32 if (!videoStream) return ''
31 33
32 const videoCodec = videoStream.codec_tag_string 34 const videoCodec = videoStream.codec_tag_string
@@ -83,6 +85,10 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
83 return 'mp4a.40.2' // Fallback 85 return 'mp4a.40.2' // Fallback
84} 86}
85 87
88// ---------------------------------------------------------------------------
89// Resolutions
90// ---------------------------------------------------------------------------
91
86function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') { 92function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
87 const configResolutions = type === 'vod' 93 const configResolutions = type === 'vod'
88 ? CONFIG.TRANSCODING.RESOLUTIONS 94 ? CONFIG.TRANSCODING.RESOLUTIONS
@@ -112,6 +118,10 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
112 return resolutionsEnabled 118 return resolutionsEnabled
113} 119}
114 120
121// ---------------------------------------------------------------------------
122// Can quick transcode
123// ---------------------------------------------------------------------------
124
115async function canDoQuickTranscode (path: string): Promise<boolean> { 125async function canDoQuickTranscode (path: string): Promise<boolean> {
116 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false 126 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
117 127
@@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
121 await canDoQuickAudioTranscode(path, probe) 131 await canDoQuickAudioTranscode(path, probe)
122} 132}
123 133
134async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
135 const parsedAudio = await getAudioStream(path, probe)
136
137 if (!parsedAudio.audioStream) return true
138
139 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
140
141 const audioBitrate = parsedAudio.bitrate
142 if (!audioBitrate) return false
143
144 const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
145 if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
146
147 const channelLayout = parsedAudio.audioStream['channel_layout']
148 // Causes playback issues with Chrome
149 if (!channelLayout || channelLayout === 'unknown') return false
150
151 return true
152}
153
124async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> { 154async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
125 const videoStream = await getVideoStreamFromFile(path, probe) 155 const videoStream = await getVideoStream(path, probe)
126 const fps = await getVideoFileFPS(path, probe) 156 const fps = await getVideoStreamFPS(path, probe)
127 const bitRate = await getVideoFileBitrate(path, probe) 157 const bitRate = await getVideoStreamBitrate(path, probe)
128 const resolutionData = await getVideoFileResolution(path, probe) 158 const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
129 159
130 // If ffprobe did not manage to guess the bitrate 160 // If ffprobe did not manage to guess the bitrate
131 if (!bitRate) return false 161 if (!bitRate) return false
132 162
133 // check video params 163 // check video params
134 if (videoStream == null) return false 164 if (!videoStream) return false
135 if (videoStream['codec_name'] !== 'h264') return false 165 if (videoStream['codec_name'] !== 'h264') return false
136 if (videoStream['pix_fmt'] !== 'yuv420p') return false 166 if (videoStream['pix_fmt'] !== 'yuv420p') return false
137 if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false 167 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
140 return true 170 return true
141} 171}
142 172
173// ---------------------------------------------------------------------------
174// Framerate
175// ---------------------------------------------------------------------------
176
143function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) { 177function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
144 return VIDEO_TRANSCODING_FPS[type].slice(0) 178 return VIDEO_TRANSCODING_FPS[type].slice(0)
145 .sort((a, b) => fps % a - fps % b)[0] 179 .sort((a, b) => fps % a - fps % b)[0]
@@ -171,21 +205,26 @@ function computeFPS (fpsArg: number, resolution: VideoResolution) {
171// --------------------------------------------------------------------------- 205// ---------------------------------------------------------------------------
172 206
173export { 207export {
174 getVideoStreamCodec, 208 // Re export ffprobe utils
175 getAudioStreamCodec, 209 getVideoStreamDimensionsInfo,
176 getVideoStreamSize, 210 buildFileMetadata,
177 getVideoFileResolution,
178 getMetadataFromFile,
179 getMaxAudioBitrate, 211 getMaxAudioBitrate,
180 getVideoStreamFromFile, 212 getVideoStream,
181 getDurationFromVideoFile, 213 getVideoStreamDuration,
182 getAudioStream, 214 getAudioStream,
183 computeFPS, 215 hasAudioStream,
184 getVideoFileFPS, 216 getVideoStreamFPS,
185 ffprobePromise, 217 ffprobePromise,
218 getVideoStreamBitrate,
219
220 getVideoStreamCodec,
221 getAudioStreamCodec,
222
223 computeFPS,
186 getClosestFramerateStandard, 224 getClosestFramerateStandard,
225
187 computeLowerResolutionsToTranscode, 226 computeLowerResolutionsToTranscode,
188 getVideoFileBitrate, 227
189 canDoQuickTranscode, 228 canDoQuickTranscode,
190 canDoQuickVideoTranscode, 229 canDoQuickVideoTranscode,
191 canDoQuickAudioTranscode 230 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 @@
1export * from './ffmpeg-commons'
2export * from './ffmpeg-edition'
3export * from './ffmpeg-encoders'
4export * from './ffmpeg-images'
5export * from './ffmpeg-live'
6export * from './ffmpeg-presets'
7export * from './ffmpeg-vod'
8export * 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 @@
1import { copy, readFile, remove, rename } from 'fs-extra' 1import { copy, readFile, remove, rename } from 'fs-extra'
2import Jimp, { read } from 'jimp' 2import Jimp, { read } from 'jimp'
3import { join } from 'path'
3import { getLowercaseExtension } from '@shared/core-utils' 4import { getLowercaseExtension } from '@shared/core-utils'
4import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
5import { convertWebPToJPG, processGIF } from './ffmpeg-utils' 6import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
6import { logger } from './logger' 7import { logger, loggerTagsFactory } from './logger'
8
9const lTags = loggerTagsFactory('image-utils')
7 10
8function generateImageFilename (extension = '.jpg') { 11function generateImageFilename (extension = '.jpg') {
9 return buildUUID() + extension 12 return buildUUID() + extension
@@ -33,10 +36,31 @@ async function processImage (
33 if (keepOriginal !== true) await remove(path) 36 if (keepOriginal !== true) await remove(path)
34} 37}
35 38
39async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
40 const pendingImageName = 'pending-' + imageName
41 const pendingImagePath = join(folder, pendingImageName)
42
43 try {
44 await generateThumbnailFromVideo(fromPath, folder, imageName)
45
46 const destination = join(folder, imageName)
47 await processImage(pendingImagePath, destination, size)
48 } catch (err) {
49 logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
50
51 try {
52 await remove(pendingImagePath)
53 } catch (err) {
54 logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
55 }
56 }
57}
58
36// --------------------------------------------------------------------------- 59// ---------------------------------------------------------------------------
37 60
38export { 61export {
39 generateImageFilename, 62 generateImageFilename,
63 generateImageFromVideoFile,
40 processImage 64 processImage
41} 65}
42 66
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
91} 91}
92 92
93function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 93function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
94 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => {
95 return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath)
96 })
97}
98
99async function createTorrentAndSetInfoHashFromPath (
100 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
101 videoFile: MVideoFile,
102 filePath: string
103) {
94 const video = extractVideo(videoOrPlaylist) 104 const video = extractVideo(videoOrPlaylist)
95 105
96 const options = { 106 const options = {
@@ -101,24 +111,22 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
101 urlList: buildUrlList(video, videoFile) 111 urlList: buildUrlList(video, videoFile)
102 } 112 }
103 113
104 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), async videoPath => { 114 const torrentContent = await createTorrentPromise(filePath, options)
105 const torrentContent = await createTorrentPromise(videoPath, options)
106 115
107 const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution) 116 const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
108 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename) 117 const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
109 logger.info('Creating torrent %s.', torrentPath) 118 logger.info('Creating torrent %s.', torrentPath)
110 119
111 await writeFile(torrentPath, torrentContent) 120 await writeFile(torrentPath, torrentContent)
112 121
113 // Remove old torrent file if it existed 122 // Remove old torrent file if it existed
114 if (videoFile.hasTorrent()) { 123 if (videoFile.hasTorrent()) {
115 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename)) 124 await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
116 } 125 }
117 126
118 const parsedTorrent = parseTorrent(torrentContent) 127 const parsedTorrent = parseTorrent(torrentContent)
119 videoFile.infoHash = parsedTorrent.infoHash 128 videoFile.infoHash = parsedTorrent.infoHash
120 videoFile.torrentFilename = torrentFilename 129 videoFile.torrentFilename = torrentFilename
121 })
122} 130}
123 131
124async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) { 132async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
@@ -177,7 +185,10 @@ function generateMagnetUri (
177export { 185export {
178 createTorrentPromise, 186 createTorrentPromise,
179 updateTorrentMetadata, 187 updateTorrentMetadata,
188
180 createTorrentAndSetInfoHash, 189 createTorrentAndSetInfoHash,
190 createTorrentAndSetInfoHashFromPath,
191
181 generateMagnetUri, 192 generateMagnetUri,
182 downloadWebTorrentVideo 193 downloadWebTorrentVideo
183} 194}
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 @@
1import config from 'config' 1import config from 'config'
2import { uniq } from 'lodash' 2import { uniq } from 'lodash'
3import { URL } from 'url' 3import { URL } from 'url'
4import { getFFmpegVersion } from '@server/helpers/ffmpeg-utils' 4import { getFFmpegVersion } from '@server/helpers/ffmpeg'
5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' 5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
6import { RecentlyAddedStrategy } from '../../shared/models/redundancy' 6import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
7import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils' 7import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
@@ -31,8 +31,7 @@ async function checkActivityPubUrls () {
31 } 31 }
32} 32}
33 33
34// Some checks on configuration files 34// Some checks on configuration files or throw if there is an error
35// Return an error message, or null if everything is okay
36function checkConfig () { 35function checkConfig () {
37 36
38 // Moved configuration keys 37 // Moved configuration keys
@@ -40,61 +39,124 @@ function checkConfig () {
40 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.') 39 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
41 } 40 }
42 41
43 // Email verification 42 checkEmailConfig()
43 checkNSFWPolicyConfig()
44 checkLocalRedundancyConfig()
45 checkRemoteRedundancyConfig()
46 checkStorageConfig()
47 checkTranscodingConfig()
48 checkBroadcastMessageConfig()
49 checkSearchConfig()
50 checkLiveConfig()
51 checkObjectStorageConfig()
52 checkVideoEditorConfig()
53}
54
55// We get db by param to not import it in this file (import orders)
56async function clientsExist () {
57 const totalClients = await OAuthClientModel.countTotal()
58
59 return totalClients !== 0
60}
61
62// We get db by param to not import it in this file (import orders)
63async function usersExist () {
64 const totalUsers = await UserModel.countTotal()
65
66 return totalUsers !== 0
67}
68
69// We get db by param to not import it in this file (import orders)
70async function applicationExist () {
71 const totalApplication = await ApplicationModel.countTotal()
72
73 return totalApplication !== 0
74}
75
76async function checkFFmpegVersion () {
77 const version = await getFFmpegVersion()
78 const { major, minor } = parseSemVersion(version)
79
80 if (major < 4 || (major === 4 && minor < 1)) {
81 logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
82 }
83}
84
85// ---------------------------------------------------------------------------
86
87export {
88 checkConfig,
89 clientsExist,
90 checkFFmpegVersion,
91 usersExist,
92 applicationExist,
93 checkActivityPubUrls
94}
95
96// ---------------------------------------------------------------------------
97
98function checkEmailConfig () {
44 if (!isEmailEnabled()) { 99 if (!isEmailEnabled()) {
45 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { 100 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
46 return 'Emailer is disabled but you require signup email verification.' 101 throw new Error('Emailer is disabled but you require signup email verification.')
47 } 102 }
48 103
49 if (CONFIG.CONTACT_FORM.ENABLED) { 104 if (CONFIG.CONTACT_FORM.ENABLED) {
50 logger.warn('Emailer is disabled so the contact form will not work.') 105 logger.warn('Emailer is disabled so the contact form will not work.')
51 } 106 }
52 } 107 }
108}
53 109
54 // NSFW policy 110function checkNSFWPolicyConfig () {
55 const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY 111 const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
56 { 112
57 const available = [ 'do_not_list', 'blur', 'display' ] 113 const available = [ 'do_not_list', 'blur', 'display' ]
58 if (available.includes(defaultNSFWPolicy) === false) { 114 if (available.includes(defaultNSFWPolicy) === false) {
59 return 'NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy 115 throw new Error('NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy)
60 }
61 } 116 }
117}
62 118
63 // Redundancies 119function checkLocalRedundancyConfig () {
64 const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES 120 const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
121
65 if (isArray(redundancyVideos)) { 122 if (isArray(redundancyVideos)) {
66 const available = [ 'most-views', 'trending', 'recently-added' ] 123 const available = [ 'most-views', 'trending', 'recently-added' ]
124
67 for (const r of redundancyVideos) { 125 for (const r of redundancyVideos) {
68 if (available.includes(r.strategy) === false) { 126 if (available.includes(r.strategy) === false) {
69 return 'Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy 127 throw new Error('Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy)
70 } 128 }
71 129
72 // Lifetime should not be < 10 hours 130 // Lifetime should not be < 10 hours
73 if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) { 131 if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) {
74 return 'Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy 132 throw new Error('Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy)
75 } 133 }
76 } 134 }
77 135
78 const filtered = uniq(redundancyVideos.map(r => r.strategy)) 136 const filtered = uniq(redundancyVideos.map(r => r.strategy))
79 if (filtered.length !== redundancyVideos.length) { 137 if (filtered.length !== redundancyVideos.length) {
80 return 'Redundancy video entries should have unique strategies' 138 throw new Error('Redundancy video entries should have unique strategies')
81 } 139 }
82 140
83 const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy 141 const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
84 if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) { 142 if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
85 return 'Min views in recently added strategy is not a number' 143 throw new Error('Min views in recently added strategy is not a number')
86 } 144 }
87 } else { 145 } else {
88 return 'Videos redundancy should be an array (you must uncomment lines containing - too)' 146 throw new Error('Videos redundancy should be an array (you must uncomment lines containing - too)')
89 } 147 }
148}
90 149
91 // Remote redundancies 150function checkRemoteRedundancyConfig () {
92 const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM 151 const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM
93 const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ]) 152 const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ])
153
94 if (acceptFromValues.has(acceptFrom) === false) { 154 if (acceptFromValues.has(acceptFrom) === false) {
95 return 'remote_redundancy.videos.accept_from has an incorrect value' 155 throw new Error('remote_redundancy.videos.accept_from has an incorrect value')
96 } 156 }
157}
97 158
159function checkStorageConfig () {
98 // Check storage directory locations 160 // Check storage directory locations
99 if (isProdInstance()) { 161 if (isProdInstance()) {
100 const configStorage = config.get('storage') 162 const configStorage = config.get('storage')
@@ -111,71 +173,76 @@ function checkConfig () {
111 if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) { 173 if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
112 logger.warn('Redundancy directory should be different than the videos folder.') 174 logger.warn('Redundancy directory should be different than the videos folder.')
113 } 175 }
176}
114 177
115 // Transcoding 178function checkTranscodingConfig () {
116 if (CONFIG.TRANSCODING.ENABLED) { 179 if (CONFIG.TRANSCODING.ENABLED) {
117 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) { 180 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
118 return 'You need to enable at least WebTorrent transcoding or HLS transcoding.' 181 throw new Error('You need to enable at least WebTorrent transcoding or HLS transcoding.')
119 } 182 }
120 183
121 if (CONFIG.TRANSCODING.CONCURRENCY <= 0) { 184 if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
122 return 'Transcoding concurrency should be > 0' 185 throw new Error('Transcoding concurrency should be > 0')
123 } 186 }
124 } 187 }
125 188
126 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) { 189 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) {
127 if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) { 190 if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) {
128 return 'Video import concurrency should be > 0' 191 throw new Error('Video import concurrency should be > 0')
129 } 192 }
130 } 193 }
194}
131 195
132 // Broadcast message 196function checkBroadcastMessageConfig () {
133 if (CONFIG.BROADCAST_MESSAGE.ENABLED) { 197 if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
134 const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL 198 const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
135 const available = [ 'info', 'warning', 'error' ] 199 const available = [ 'info', 'warning', 'error' ]
136 200
137 if (available.includes(currentLevel) === false) { 201 if (available.includes(currentLevel) === false) {
138 return 'Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel 202 throw new Error('Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel)
139 } 203 }
140 } 204 }
205}
141 206
142 // Search index 207function checkSearchConfig () {
143 if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) { 208 if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
144 if (CONFIG.SEARCH.REMOTE_URI.USERS === false) { 209 if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
145 return 'You cannot enable search index without enabling remote URI search for users.' 210 throw new Error('You cannot enable search index without enabling remote URI search for users.')
146 } 211 }
147 } 212 }
213}
148 214
149 // Live 215function checkLiveConfig () {
150 if (CONFIG.LIVE.ENABLED === true) { 216 if (CONFIG.LIVE.ENABLED === true) {
151 if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) { 217 if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
152 return 'Live allow replay cannot be enabled if transcoding is not enabled.' 218 throw new Error('Live allow replay cannot be enabled if transcoding is not enabled.')
153 } 219 }
154 220
155 if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) { 221 if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) {
156 return 'You must enable at least RTMP or RTMPS' 222 throw new Error('You must enable at least RTMP or RTMPS')
157 } 223 }
158 224
159 if (CONFIG.LIVE.RTMPS.ENABLED) { 225 if (CONFIG.LIVE.RTMPS.ENABLED) {
160 if (!CONFIG.LIVE.RTMPS.KEY_FILE) { 226 if (!CONFIG.LIVE.RTMPS.KEY_FILE) {
161 return 'You must specify a key file to enabled RTMPS' 227 throw new Error('You must specify a key file to enabled RTMPS')
162 } 228 }
163 229
164 if (!CONFIG.LIVE.RTMPS.CERT_FILE) { 230 if (!CONFIG.LIVE.RTMPS.CERT_FILE) {
165 return 'You must specify a cert file to enable RTMPS' 231 throw new Error('You must specify a cert file to enable RTMPS')
166 } 232 }
167 } 233 }
168 } 234 }
235}
169 236
170 // Object storage 237function checkObjectStorageConfig () {
171 if (CONFIG.OBJECT_STORAGE.ENABLED === true) { 238 if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
172 239
173 if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) { 240 if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) {
174 return 'videos_bucket should be set when object storage support is enabled.' 241 throw new Error('videos_bucket should be set when object storage support is enabled.')
175 } 242 }
176 243
177 if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) { 244 if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) {
178 return 'streaming_playlists_bucket should be set when object storage support is enabled.' 245 throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.')
179 } 246 }
180 247
181 if ( 248 if (
@@ -183,53 +250,18 @@ function checkConfig () {
183 CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX 250 CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
184 ) { 251 ) {
185 if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') { 252 if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') {
186 return 'Object storage bucket prefixes should be set when the same bucket is used for both types of video.' 253 throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.')
187 } else {
188 return 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
189 } 254 }
255
256 throw new Error(
257 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
258 )
190 } 259 }
191 } 260 }
192
193 return null
194}
195
196// We get db by param to not import it in this file (import orders)
197async function clientsExist () {
198 const totalClients = await OAuthClientModel.countTotal()
199
200 return totalClients !== 0
201}
202
203// We get db by param to not import it in this file (import orders)
204async function usersExist () {
205 const totalUsers = await UserModel.countTotal()
206
207 return totalUsers !== 0
208} 261}
209 262
210// We get db by param to not import it in this file (import orders) 263function checkVideoEditorConfig () {
211async function applicationExist () { 264 if (CONFIG.VIDEO_EDITOR.ENABLED === true && CONFIG.TRANSCODING.ENABLED === false) {
212 const totalApplication = await ApplicationModel.countTotal() 265 throw new Error('Video editor cannot be enabled if transcoding is disabled')
213
214 return totalApplication !== 0
215}
216
217async function checkFFmpegVersion () {
218 const version = await getFFmpegVersion()
219 const { major, minor } = parseSemVersion(version)
220
221 if (major < 4 || (major === 4 && minor < 1)) {
222 logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
223 } 266 }
224} 267}
225
226// ---------------------------------------------------------------------------
227
228export {
229 checkConfig,
230 clientsExist,
231 checkFFmpegVersion,
232 usersExist,
233 applicationExist,
234 checkActivityPubUrls
235}
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 () {
30 'transcoding.profile', 'transcoding.concurrency', 30 'transcoding.profile', 'transcoding.concurrency',
31 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', 31 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
32 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 32 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
33 'transcoding.resolutions.2160p', 33 'transcoding.resolutions.2160p', 'video_editor.enabled',
34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled', 34 'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
35 'trending.videos.interval_days', 35 'trending.videos.interval_days',
36 'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth', 36 '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 = {
324 } 324 }
325 } 325 }
326 }, 326 },
327 VIDEO_EDITOR: {
328 get ENABLED () { return config.get<boolean>('video_editor.enabled') }
329 },
327 IMPORT: { 330 IMPORT: {
328 VIDEOS: { 331 VIDEOS: {
329 get CONCURRENCY () { return config.get<number>('import.videos.concurrency') }, 332 get CONCURRENCY () { return config.get<number>('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 } = {
152 'activitypub-refresher': 1, 152 'activitypub-refresher': 1,
153 'video-redundancy': 1, 153 'video-redundancy': 1,
154 'video-live-ending': 1, 154 'video-live-ending': 1,
155 'video-edition': 1,
155 'move-to-object-storage': 3 156 'move-to-object-storage': 3
156} 157}
157// Excluded keys are jobs that can be configured by admins 158// Excluded keys are jobs that can be configured by admins
@@ -168,6 +169,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
168 'activitypub-refresher': 1, 169 'activitypub-refresher': 1,
169 'video-redundancy': 1, 170 'video-redundancy': 1,
170 'video-live-ending': 10, 171 'video-live-ending': 10,
172 'video-edition': 1,
171 'move-to-object-storage': 1 173 'move-to-object-storage': 1
172} 174}
173const JOB_TTL: { [id in JobType]: number } = { 175const JOB_TTL: { [id in JobType]: number } = {
@@ -178,6 +180,7 @@ const JOB_TTL: { [id in JobType]: number } = {
178 'activitypub-cleaner': 1000 * 3600, // 1 hour 180 'activitypub-cleaner': 1000 * 3600, // 1 hour
179 'video-file-import': 1000 * 3600, // 1 hour 181 'video-file-import': 1000 * 3600, // 1 hour
180 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long 182 'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
183 'video-edition': 1000 * 3600 * 10, // 10 hours
181 'video-import': 1000 * 3600 * 2, // 2 hours 184 'video-import': 1000 * 3600 * 2, // 2 hours
182 'email': 60000 * 10, // 10 minutes 185 'email': 60000 * 10, // 10 minutes
183 'actor-keys': 60000 * 20, // 20 minutes 186 'actor-keys': 60000 * 20, // 20 minutes
@@ -351,6 +354,10 @@ const CONSTRAINTS_FIELDS = {
351 }, 354 },
352 COMMONS: { 355 COMMONS: {
353 URL: { min: 5, max: 2000 } // Length 356 URL: { min: 5, max: 2000 } // Length
357 },
358 VIDEO_EDITOR: {
359 TASKS: { min: 1, max: 10 }, // Number of tasks
360 CUT_TIME: { min: 0 } // Value
354 } 361 }
355} 362}
356 363
@@ -365,6 +372,7 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
365 MIN: 1, 372 MIN: 1,
366 STANDARD: [ 24, 25, 30 ], 373 STANDARD: [ 24, 25, 30 ],
367 HD_STANDARD: [ 50, 60 ], 374 HD_STANDARD: [ 50, 60 ],
375 AUDIO_MERGE: 25,
368 AVERAGE: 30, 376 AVERAGE: 30,
369 MAX: 60, 377 MAX: 60,
370 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) 378 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
@@ -434,7 +442,8 @@ const VIDEO_STATES: { [ id in VideoState ]: string } = {
434 [VideoState.LIVE_ENDED]: 'Livestream ended', 442 [VideoState.LIVE_ENDED]: 'Livestream ended',
435 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage', 443 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
436 [VideoState.TRANSCODING_FAILED]: 'Transcoding failed', 444 [VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
437 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed' 445 [VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed',
446 [VideoState.TO_EDIT]: 'To edit*'
438} 447}
439 448
440const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = { 449const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
@@ -855,6 +864,16 @@ const FILES_CONTENT_HASH = {
855 864
856// --------------------------------------------------------------------------- 865// ---------------------------------------------------------------------------
857 866
867const VIDEO_FILTERS = {
868 WATERMARK: {
869 SIZE_RATIO: 1 / 10,
870 HORIZONTAL_MARGIN_RATIO: 1 / 20,
871 VERTICAL_MARGIN_RATIO: 1 / 20
872 }
873}
874
875// ---------------------------------------------------------------------------
876
858export { 877export {
859 WEBSERVER, 878 WEBSERVER,
860 API_VERSION, 879 API_VERSION,
@@ -893,6 +912,7 @@ export {
893 PLUGIN_GLOBAL_CSS_FILE_NAME, 912 PLUGIN_GLOBAL_CSS_FILE_NAME,
894 PLUGIN_GLOBAL_CSS_PATH, 913 PLUGIN_GLOBAL_CSS_PATH,
895 PRIVATE_RSA_KEY_SIZE, 914 PRIVATE_RSA_KEY_SIZE,
915 VIDEO_FILTERS,
896 ROUTE_CACHE_LIFETIME, 916 ROUTE_CACHE_LIFETIME,
897 SORTABLE_COLUMNS, 917 SORTABLE_COLUMNS,
898 HLS_STREAMING_PLAYLIST_DIRECTORY, 918 HLS_STREAMING_PLAYLIST_DIRECTORY,
diff --git a/server/initializers/migrations/0075-video-resolutions.ts b/server/initializers/migrations/0075-video-resolutions.ts
index 6e8e47acb..8cd47496e 100644
--- a/server/initializers/migrations/0075-video-resolutions.ts
+++ b/server/initializers/migrations/0075-video-resolutions.ts
@@ -1,8 +1,8 @@
1import * as Sequelize from 'sequelize' 1import { readdir, rename } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import * as Sequelize from 'sequelize'
4import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg/ffprobe-utils'
3import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
4import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
5import { readdir, rename } from 'fs-extra'
6 6
7function up (utils: { 7function up (utils: {
8 transaction: Sequelize.Transaction 8 transaction: Sequelize.Transaction
@@ -26,7 +26,7 @@ function up (utils: {
26 const uuid = matches[1] 26 const uuid = matches[1]
27 const ext = matches[2] 27 const ext = matches[2]
28 28
29 const p = getVideoFileResolution(join(videoFileDir, videoFile)) 29 const p = getVideoStreamDimensionsInfo(join(videoFileDir, videoFile))
30 .then(async ({ resolution }) => { 30 .then(async ({ resolution }) => {
31 const oldTorrentName = uuid + '.torrent' 31 const oldTorrentName = uuid + '.torrent'
32 const newTorrentName = uuid + '-' + resolution + '.torrent' 32 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'
4import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models' 4import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models'
5import { sha256 } from '@shared/extra-utils' 5import { sha256 } from '@shared/extra-utils'
6import { VideoStorage } from '@shared/models' 6import { VideoStorage } from '@shared/models'
7import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamSize } from '../helpers/ffprobe-utils' 7import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
8import { logger } from '../helpers/logger' 8import { logger } from '../helpers/logger'
9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests' 9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
10import { generateRandomString } from '../helpers/utils' 10import { generateRandomString } from '../helpers/utils'
@@ -40,10 +40,10 @@ async function updateMasterHLSPlaylist (video: MVideo, playlist: MStreamingPlayl
40 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename) 40 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
41 41
42 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => { 42 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
43 const size = await getVideoStreamSize(videoFilePath) 43 const size = await getVideoStreamDimensionsInfo(videoFilePath)
44 44
45 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file) 45 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
46 const resolution = `RESOLUTION=${size.width}x${size.height}` 46 const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
47 47
48 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}` 48 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
49 if (file.fps) line += ',FRAME-RATE=' + file.fps 49 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 @@
1import { Job } from 'bull'
2import { move, remove } from 'fs-extra'
3import { join } from 'path'
4import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg'
5import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
6import { CONFIG } from '@server/initializers/config'
7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
8import { generateWebTorrentVideoFilename } from '@server/lib/paths'
9import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
10import { isAbleToUploadVideo } from '@server/lib/user'
11import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video'
12import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor'
13import { VideoPathManager } from '@server/lib/video-path-manager'
14import { buildNextVideoState } from '@server/lib/video-state'
15import { UserModel } from '@server/models/user/user'
16import { VideoModel } from '@server/models/video/video'
17import { VideoFileModel } from '@server/models/video/video-file'
18import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
19import { getLowercaseExtension, pick } from '@shared/core-utils'
20import {
21 buildFileMetadata,
22 buildUUID,
23 ffprobePromise,
24 getFileSize,
25 getVideoStreamDimensionsInfo,
26 getVideoStreamDuration,
27 getVideoStreamFPS
28} from '@shared/extra-utils'
29import {
30 VideoEditionPayload,
31 VideoEditionTaskPayload,
32 VideoEditorTask,
33 VideoEditorTaskCutPayload,
34 VideoEditorTaskIntroPayload,
35 VideoEditorTaskOutroPayload,
36 VideoEditorTaskWatermarkPayload,
37 VideoState
38} from '@shared/models'
39import { logger, loggerTagsFactory } from '../../../helpers/logger'
40
41const lTagsBase = loggerTagsFactory('video-edition')
42
43async function processVideoEdition (job: Job) {
44 const payload = job.data as VideoEditionPayload
45
46 logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
47
48 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
49
50 // No video, maybe deleted?
51 if (!video) {
52 logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
53 return undefined
54 }
55
56 await checkUserQuotaOrThrow(video, payload)
57
58 const inputFile = video.getMaxQualityFile()
59
60 const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
61 let tmpInputFilePath: string
62 let outputPath: string
63
64 for (const task of payload.tasks) {
65 const outputFilename = buildUUID() + inputFile.extname
66 outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
67
68 await processTask({
69 inputPath: tmpInputFilePath ?? originalFilePath,
70 video,
71 outputPath,
72 task
73 })
74
75 if (tmpInputFilePath) await remove(tmpInputFilePath)
76
77 // For the next iteration
78 tmpInputFilePath = outputPath
79 }
80
81 return outputPath
82 })
83
84 logger.info('Video edition ended for video %s.', video.uuid)
85
86 const newFile = await buildNewFile(video, editionResultPath)
87
88 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
89 await move(editionResultPath, outputPath)
90
91 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
92
93 await removeAllFiles(video, newFile)
94
95 await newFile.save()
96
97 video.state = buildNextVideoState()
98 video.duration = await getVideoStreamDuration(outputPath)
99 await video.save()
100
101 await federateVideoIfNeeded(video, false, undefined)
102
103 if (video.state === VideoState.TO_TRANSCODE) {
104 const user = await UserModel.loadByVideoId(video.id)
105
106 await addOptimizeOrMergeAudioJob(video, newFile, user, false)
107 } else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
108 await addMoveToObjectStorageJob(video, false)
109 }
110}
111
112// ---------------------------------------------------------------------------
113
114export {
115 processVideoEdition
116}
117
118// ---------------------------------------------------------------------------
119
120type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
121 inputPath: string
122 outputPath: string
123 video: MVideo
124 task: T
125}
126
127const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
128 'add-intro': processAddIntroOutro,
129 'add-outro': processAddIntroOutro,
130 'cut': processCut,
131 'add-watermark': processAddWatermark
132}
133
134async function processTask (options: TaskProcessorOptions) {
135 const { video, task } = options
136
137 logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
138
139 const processor = taskProcessors[options.task.name]
140 if (!process) throw new Error('Unknown task ' + task.name)
141
142 return processor(options)
143}
144
145function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
146 const { task } = options
147
148 return addIntroOutro({
149 ...pick(options, [ 'inputPath', 'outputPath' ]),
150
151 introOutroPath: task.options.file,
152 type: task.name === 'add-intro'
153 ? 'intro'
154 : 'outro',
155
156 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
157 profile: CONFIG.TRANSCODING.PROFILE
158 })
159}
160
161function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) {
162 const { task } = options
163
164 return cutVideo({
165 ...pick(options, [ 'inputPath', 'outputPath' ]),
166
167 start: task.options.start,
168 end: task.options.end
169 })
170}
171
172function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
173 const { task } = options
174
175 return addWatermark({
176 ...pick(options, [ 'inputPath', 'outputPath' ]),
177
178 watermarkPath: task.options.file,
179
180 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
181 profile: CONFIG.TRANSCODING.PROFILE
182 })
183}
184
185async function buildNewFile (video: MVideoId, path: string) {
186 const videoFile = new VideoFileModel({
187 extname: getLowercaseExtension(path),
188 size: await getFileSize(path),
189 metadata: await buildFileMetadata(path),
190 videoStreamingPlaylistId: null,
191 videoId: video.id
192 })
193
194 const probe = await ffprobePromise(path)
195
196 videoFile.fps = await getVideoStreamFPS(path, probe)
197 videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
198
199 videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
200
201 return videoFile
202}
203
204async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
205 const hls = video.getHLSPlaylist()
206
207 if (hls) {
208 await video.removeStreamingPlaylistFiles(hls)
209 await hls.destroy()
210 }
211
212 for (const file of video.VideoFiles) {
213 if (file.id === webTorrentFileException.id) continue
214
215 await video.removeWebTorrentFileAndTorrent(file)
216 await file.destroy()
217 }
218}
219
220async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) {
221 const user = await UserModel.loadByVideoId(video.id)
222
223 const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file
224
225 const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
226 if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
227 throw new Error('Quota exceeded for this user to edit the video')
228 }
229}
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 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { copy, stat } from 'fs-extra' 2import { copy, stat } from 'fs-extra'
3import { getLowercaseExtension } from '@shared/core-utils'
4import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 3import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
5import { CONFIG } from '@server/initializers/config' 4import { CONFIG } from '@server/initializers/config'
6import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
7import { generateWebTorrentVideoFilename } from '@server/lib/paths' 6import { generateWebTorrentVideoFilename } from '@server/lib/paths'
8import { addMoveToObjectStorageJob } from '@server/lib/video' 7import { addMoveToObjectStorageJob } from '@server/lib/video'
9import { VideoPathManager } from '@server/lib/video-path-manager' 8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { VideoModel } from '@server/models/video/video'
10import { VideoFileModel } from '@server/models/video/video-file'
10import { MVideoFullLight } from '@server/types/models' 11import { MVideoFullLight } from '@server/types/models'
12import { getLowercaseExtension } from '@shared/core-utils'
11import { VideoFileImportPayload, VideoStorage } from '@shared/models' 13import { VideoFileImportPayload, VideoStorage } from '@shared/models'
12import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 14import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
13import { logger } from '../../../helpers/logger' 15import { logger } from '../../../helpers/logger'
14import { VideoModel } from '../../../models/video/video'
15import { VideoFileModel } from '../../../models/video/video-file'
16 16
17async function processVideoFileImport (job: Job) { 17async function processVideoFileImport (job: Job) {
18 const payload = job.data as VideoFileImportPayload 18 const payload = job.data as VideoFileImportPayload
@@ -45,9 +45,9 @@ export {
45// --------------------------------------------------------------------------- 45// ---------------------------------------------------------------------------
46 46
47async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) { 47async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
48 const { resolution } = await getVideoFileResolution(inputFilePath) 48 const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath)
49 const { size } = await stat(inputFilePath) 49 const { size } = await stat(inputFilePath)
50 const fps = await getVideoFileFPS(inputFilePath) 50 const fps = await getVideoStreamFPS(inputFilePath)
51 51
52 const fileExt = getLowercaseExtension(inputFilePath) 52 const fileExt = getLowercaseExtension(inputFilePath)
53 53
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 {
25 VideoResolution, 25 VideoResolution,
26 VideoState 26 VideoState
27} from '@shared/models' 27} from '@shared/models'
28import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 28import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
29import { logger } from '../../../helpers/logger' 29import { logger } from '../../../helpers/logger'
30import { getSecureTorrentName } from '../../../helpers/utils' 30import { getSecureTorrentName } from '../../../helpers/utils'
31import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent' 31import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
@@ -121,10 +121,10 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
121 121
122 const { resolution } = await isAudioFile(tempVideoPath, probe) 122 const { resolution } = await isAudioFile(tempVideoPath, probe)
123 ? { resolution: VideoResolution.H_NOVIDEO } 123 ? { resolution: VideoResolution.H_NOVIDEO }
124 : await getVideoFileResolution(tempVideoPath) 124 : await getVideoStreamDimensionsInfo(tempVideoPath)
125 125
126 const fps = await getVideoFileFPS(tempVideoPath, probe) 126 const fps = await getVideoStreamFPS(tempVideoPath, probe)
127 const duration = await getDurationFromVideoFile(tempVideoPath, probe) 127 const duration = await getVideoStreamDuration(tempVideoPath, probe)
128 128
129 // Prepare video file object for creation in database 129 // Prepare video file object for creation in database
130 const fileExt = getLowercaseExtension(tempVideoPath) 130 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 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { pathExists, readdir, remove } from 'fs-extra' 2import { pathExists, readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' 4import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
5import { VIDEO_LIVE } from '@server/initializers/constants' 5import { VIDEO_LIVE } from '@server/initializers/constants'
6import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live' 6import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
7import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths' 7import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths'
8import { generateVideoMiniature } from '@server/lib/thumbnail' 8import { generateVideoMiniature } from '@server/lib/thumbnail'
9import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding' 9import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
10import { VideoPathManager } from '@server/lib/video-path-manager' 10import { VideoPathManager } from '@server/lib/video-path-manager'
11import { moveToNextState } from '@server/lib/video-state' 11import { moveToNextState } from '@server/lib/video-state'
12import { VideoModel } from '@server/models/video/video' 12import { VideoModel } from '@server/models/video/video'
@@ -96,7 +96,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
96 const probe = await ffprobePromise(concatenatedTsFilePath) 96 const probe = await ffprobePromise(concatenatedTsFilePath)
97 const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe) 97 const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
98 98
99 const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe) 99 const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
100 100
101 const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({ 101 const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
102 video: videoWithFiles, 102 video: videoWithFiles,
@@ -107,7 +107,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
107 }) 107 })
108 108
109 if (!durationDone) { 109 if (!durationDone) {
110 videoWithFiles.duration = await getDurationFromVideoFile(outputPath) 110 videoWithFiles.duration = await getVideoStreamDuration(outputPath)
111 await videoWithFiles.save() 111 await videoWithFiles.save()
112 112
113 durationDone = true 113 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 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils' 2import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg'
3import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video' 3import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
4import { VideoPathManager } from '@server/lib/video-path-manager' 4import { VideoPathManager } from '@server/lib/video-path-manager'
5import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state' 5import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
@@ -16,7 +16,7 @@ import {
16 VideoTranscodingPayload 16 VideoTranscodingPayload
17} from '@shared/models' 17} from '@shared/models'
18import { retryTransactionWrapper } from '../../../helpers/database-utils' 18import { retryTransactionWrapper } from '../../../helpers/database-utils'
19import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils' 19import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg'
20import { logger, loggerTagsFactory } from '../../../helpers/logger' 20import { logger, loggerTagsFactory } from '../../../helpers/logger'
21import { CONFIG } from '../../../initializers/config' 21import { CONFIG } from '../../../initializers/config'
22import { VideoModel } from '../../../models/video/video' 22import { VideoModel } from '../../../models/video/video'
@@ -25,7 +25,7 @@ import {
25 mergeAudioVideofile, 25 mergeAudioVideofile,
26 optimizeOriginalVideofile, 26 optimizeOriginalVideofile,
27 transcodeNewWebTorrentResolution 27 transcodeNewWebTorrentResolution
28} from '../../transcoding/video-transcoding' 28} from '../../transcoding/transcoding'
29 29
30type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void> 30type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
31 31
@@ -174,10 +174,10 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
174async function onVideoFirstWebTorrentTranscoding ( 174async function onVideoFirstWebTorrentTranscoding (
175 videoArg: MVideoWithFile, 175 videoArg: MVideoWithFile,
176 payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload, 176 payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload,
177 transcodeType: TranscodeOptionsType, 177 transcodeType: TranscodeVODOptionsType,
178 user: MUserId 178 user: MUserId
179) { 179) {
180 const { resolution, isPortraitMode, audioStream } = await videoArg.getMaxQualityFileInfo() 180 const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile()
181 181
182 // Maybe the video changed in database, refresh it 182 // Maybe the video changed in database, refresh it
183 const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid) 183 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 {
14 JobType, 14 JobType,
15 MoveObjectStoragePayload, 15 MoveObjectStoragePayload,
16 RefreshPayload, 16 RefreshPayload,
17 VideoEditionPayload,
17 VideoFileImportPayload, 18 VideoFileImportPayload,
18 VideoImportPayload, 19 VideoImportPayload,
19 VideoLiveEndingPayload, 20 VideoLiveEndingPayload,
@@ -31,6 +32,7 @@ import { refreshAPObject } from './handlers/activitypub-refresher'
31import { processActorKeys } from './handlers/actor-keys' 32import { processActorKeys } from './handlers/actor-keys'
32import { processEmail } from './handlers/email' 33import { processEmail } from './handlers/email'
33import { processMoveToObjectStorage } from './handlers/move-to-object-storage' 34import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
35import { processVideoEdition } from './handlers/video-edition'
34import { processVideoFileImport } from './handlers/video-file-import' 36import { processVideoFileImport } from './handlers/video-file-import'
35import { processVideoImport } from './handlers/video-import' 37import { processVideoImport } from './handlers/video-import'
36import { processVideoLiveEnding } from './handlers/video-live-ending' 38import { processVideoLiveEnding } from './handlers/video-live-ending'
@@ -53,6 +55,7 @@ type CreateJobArgument =
53 { type: 'actor-keys', payload: ActorKeysPayload } | 55 { type: 'actor-keys', payload: ActorKeysPayload } |
54 { type: 'video-redundancy', payload: VideoRedundancyPayload } | 56 { type: 'video-redundancy', payload: VideoRedundancyPayload } |
55 { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } | 57 { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
58 { type: 'video-edition', payload: VideoEditionPayload } |
56 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } 59 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
57 60
58export type CreateJobOptions = { 61export type CreateJobOptions = {
@@ -75,7 +78,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
75 'video-live-ending': processVideoLiveEnding, 78 'video-live-ending': processVideoLiveEnding,
76 'actor-keys': processActorKeys, 79 'actor-keys': processActorKeys,
77 'video-redundancy': processVideoRedundancy, 80 'video-redundancy': processVideoRedundancy,
78 'move-to-object-storage': processMoveToObjectStorage 81 'move-to-object-storage': processMoveToObjectStorage,
82 'video-edition': processVideoEdition
79} 83}
80 84
81const jobTypes: JobType[] = [ 85const jobTypes: JobType[] = [
@@ -93,7 +97,8 @@ const jobTypes: JobType[] = [
93 'video-redundancy', 97 'video-redundancy',
94 'actor-keys', 98 'actor-keys',
95 'video-live-ending', 99 'video-live-ending',
96 'move-to-object-storage' 100 'move-to-object-storage',
101 'video-edition'
97] 102]
98 103
99class JobQueue { 104class 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'
5import { 5import {
6 computeLowerResolutionsToTranscode, 6 computeLowerResolutionsToTranscode,
7 ffprobePromise, 7 ffprobePromise,
8 getVideoFileBitrate, 8 getVideoStreamBitrate,
9 getVideoFileFPS, 9 getVideoStreamFPS,
10 getVideoFileResolution 10 getVideoStreamDimensionsInfo
11} from '@server/helpers/ffprobe-utils' 11} from '@server/helpers/ffmpeg'
12import { logger, loggerTagsFactory } from '@server/helpers/logger' 12import { logger, loggerTagsFactory } from '@server/helpers/logger'
13import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' 13import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
14import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants' 14import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
@@ -226,9 +226,9 @@ class LiveManager {
226 const probe = await ffprobePromise(inputUrl) 226 const probe = await ffprobePromise(inputUrl)
227 227
228 const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([ 228 const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([
229 getVideoFileResolution(inputUrl, probe), 229 getVideoStreamDimensionsInfo(inputUrl, probe),
230 getVideoFileFPS(inputUrl, probe), 230 getVideoStreamFPS(inputUrl, probe),
231 getVideoFileBitrate(inputUrl, probe) 231 getVideoStreamBitrate(inputUrl, probe)
232 ]) 232 ])
233 233
234 logger.info( 234 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'
5import { appendFile, ensureDir, readFile, stat } from 'fs-extra' 5import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
6import { basename, join } from 'path' 6import { basename, join } from 'path'
7import { EventEmitter } from 'stream' 7import { EventEmitter } from 'stream'
8import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils' 8import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg'
9import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger' 9import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
10import { CONFIG } from '@server/initializers/config' 10import { CONFIG } from '@server/initializers/config'
11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants' 11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
12import { VideoFileModel } from '@server/models/video/video-file' 12import { VideoFileModel } from '@server/models/video/video-file'
13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models' 13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
14import { getLiveDirectory } from '../../paths' 14import { getLiveDirectory } from '../../paths'
15import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles' 15import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles'
16import { isAbleToUploadVideo } from '../../user' 16import { isAbleToUploadVideo } from '../../user'
17import { LiveQuotaStore } from '../live-quota-store' 17import { LiveQuotaStore } from '../live-quota-store'
18import { LiveSegmentShaStore } from '../live-segment-sha-store' 18import { 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 @@
1import express from 'express' 1import express from 'express'
2import { join } from 'path' 2import { join } from 'path'
3import { ffprobePromise } from '@server/helpers/ffprobe-utils' 3import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils'
4import { buildLogger } from '@server/helpers/logger' 4import { buildLogger } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config' 5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants' 6import { 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 {
21 VideoPlaylistPrivacy, 21 VideoPlaylistPrivacy,
22 VideoPrivacy 22 VideoPrivacy
23} from '@shared/models' 23} from '@shared/models'
24import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles' 24import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles'
25import { buildPluginHelpers } from './plugin-helpers-builder' 25import { buildPluginHelpers } from './plugin-helpers-builder'
26 26
27export class RegisterHelpers { 27export 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
8import { Hooks } from './plugins/hooks' 8import { Hooks } from './plugins/hooks'
9import { PluginManager } from './plugins/plugin-manager' 9import { PluginManager } from './plugins/plugin-manager'
10import { getThemeOrDefault } from './plugins/theme-utils' 10import { getThemeOrDefault } from './plugins/theme-utils'
11import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' 11import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles'
12 12
13/** 13/**
14 * 14 *
@@ -151,6 +151,9 @@ class ServerConfigManager {
151 port: CONFIG.LIVE.RTMP.PORT 151 port: CONFIG.LIVE.RTMP.PORT
152 } 152 }
153 }, 153 },
154 videoEditor: {
155 enabled: CONFIG.VIDEO_EDITOR.ENABLED
156 },
154 import: { 157 import: {
155 videos: { 158 videos: {
156 http: { 159 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 @@
1import { join } from 'path' 1import { join } from 'path'
2import { ThumbnailType } from '../../shared/models/videos/thumbnail.type' 2import { ThumbnailType } from '@shared/models'
3import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils' 3import { generateImageFilename, generateImageFromVideoFile, processImage } from '../helpers/image-utils'
4import { generateImageFilename, processImage } from '../helpers/image-utils'
5import { downloadImage } from '../helpers/requests' 4import { downloadImage } from '../helpers/requests'
6import { CONFIG } from '../initializers/config' 5import { CONFIG } from '../initializers/config'
7import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' 6import { 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
index dcc8d4c5c..ba98a11ca 100644
--- a/server/lib/transcoding/video-transcoding-profiles.ts
+++ b/server/lib/transcoding/default-transcoding-profiles.ts
@@ -2,8 +2,14 @@
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils' 3import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
4import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos' 4import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
5import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils' 5import {
6import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '../../helpers/ffprobe-utils' 6 buildStreamSuffix,
7 canDoQuickAudioTranscode,
8 ffprobePromise,
9 getAudioStream,
10 getMaxAudioBitrate,
11 resetSupportedEncoders
12} from '../../helpers/ffmpeg'
7 13
8/** 14/**
9 * 15 *
@@ -15,8 +21,14 @@ import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBi
15 * * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate 21 * * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
16 */ 22 */
17 23
24// ---------------------------------------------------------------------------
25// Default builders
26// ---------------------------------------------------------------------------
27
18const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => { 28const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
19 const { fps, inputRatio, inputBitrate, resolution } = options 29 const { fps, inputRatio, inputBitrate, resolution } = options
30
31 // TODO: remove in 4.2, fps is not optional anymore
20 if (!fps) return { outputOptions: [ ] } 32 if (!fps) return { outputOptions: [ ] }
21 33
22 const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution }) 34 const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
@@ -45,10 +57,10 @@ const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOp
45 } 57 }
46} 58}
47 59
48const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum }) => { 60const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => {
49 const probe = await ffprobePromise(input) 61 const probe = await ffprobePromise(input)
50 62
51 if (await canDoQuickAudioTranscode(input, probe)) { 63 if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) {
52 logger.debug('Copy audio stream %s by AAC encoder.', input) 64 logger.debug('Copy audio stream %s by AAC encoder.', input)
53 return { copy: true, outputOptions: [ ] } 65 return { copy: true, outputOptions: [ ] }
54 } 66 }
@@ -75,7 +87,10 @@ const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum })
75 return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] } 87 return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
76} 88}
77 89
78// Used to get and update available encoders 90// ---------------------------------------------------------------------------
91// Profile manager to get and change default profiles
92// ---------------------------------------------------------------------------
93
79class VideoTranscodingProfilesManager { 94class VideoTranscodingProfilesManager {
80 private static instance: VideoTranscodingProfilesManager 95 private static instance: VideoTranscodingProfilesManager
81 96
diff --git a/server/lib/transcoding/video-transcoding.ts b/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'
6import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 6import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
7import { VideoResolution, VideoStorage } from '../../../shared/models/videos' 7import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' 8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
9import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils' 9import {
10import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils' 10 canDoQuickTranscode,
11 getVideoStreamDuration,
12 buildFileMetadata,
13 getVideoStreamFPS,
14 transcodeVOD,
15 TranscodeVODOptions,
16 TranscodeVODOptionsType
17} from '../../helpers/ffmpeg'
11import { CONFIG } from '../../initializers/config' 18import { CONFIG } from '../../initializers/config'
12import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants' 19import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
13import { VideoFileModel } from '../../models/video/video-file' 20import { VideoFileModel } from '../../models/video/video-file'
@@ -21,7 +28,7 @@ import {
21 getHlsResolutionPlaylistFilename 28 getHlsResolutionPlaylistFilename
22} from '../paths' 29} from '../paths'
23import { VideoPathManager } from '../video-path-manager' 30import { VideoPathManager } from '../video-path-manager'
24import { VideoTranscodingProfilesManager } from './video-transcoding-profiles' 31import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
25 32
26/** 33/**
27 * 34 *
@@ -38,13 +45,13 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
38 return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => { 45 return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => {
39 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) 46 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
40 47
41 const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath) 48 const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
42 ? 'quick-transcode' 49 ? 'quick-transcode'
43 : 'video' 50 : 'video'
44 51
45 const resolution = toEven(inputVideoFile.resolution) 52 const resolution = toEven(inputVideoFile.resolution)
46 53
47 const transcodeOptions: TranscodeOptions = { 54 const transcodeOptions: TranscodeVODOptions = {
48 type: transcodeType, 55 type: transcodeType,
49 56
50 inputPath: videoInputPath, 57 inputPath: videoInputPath,
@@ -59,7 +66,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
59 } 66 }
60 67
61 // Could be very long! 68 // Could be very long!
62 await transcode(transcodeOptions) 69 await transcodeVOD(transcodeOptions)
63 70
64 // Important to do this before getVideoFilename() to take in account the new filename 71 // Important to do this before getVideoFilename() to take in account the new filename
65 inputVideoFile.extname = newExtname 72 inputVideoFile.extname = newExtname
@@ -121,7 +128,7 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
121 job 128 job
122 } 129 }
123 130
124 await transcode(transcodeOptions) 131 await transcodeVOD(transcodeOptions)
125 132
126 return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath) 133 return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
127 }) 134 })
@@ -158,7 +165,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
158 } 165 }
159 166
160 try { 167 try {
161 await transcode(transcodeOptions) 168 await transcodeVOD(transcodeOptions)
162 169
163 await remove(audioInputPath) 170 await remove(audioInputPath)
164 await remove(tmpPreviewPath) 171 await remove(tmpPreviewPath)
@@ -175,7 +182,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
175 const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile) 182 const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile)
176 // ffmpeg generated a new video file, so update the video duration 183 // ffmpeg generated a new video file, so update the video duration
177 // See https://trac.ffmpeg.org/ticket/5456 184 // See https://trac.ffmpeg.org/ticket/5456
178 video.duration = await getDurationFromVideoFile(videoTranscodedPath) 185 video.duration = await getVideoStreamDuration(videoTranscodedPath)
179 await video.save() 186 await video.save()
180 187
181 return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) 188 return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
@@ -239,8 +246,8 @@ async function onWebTorrentVideoFileTranscoding (
239 outputPath: string 246 outputPath: string
240) { 247) {
241 const stats = await stat(transcodingPath) 248 const stats = await stat(transcodingPath)
242 const fps = await getVideoFileFPS(transcodingPath) 249 const fps = await getVideoStreamFPS(transcodingPath)
243 const metadata = await getMetadataFromFile(transcodingPath) 250 const metadata = await buildFileMetadata(transcodingPath)
244 251
245 await move(transcodingPath, outputPath, { overwrite: true }) 252 await move(transcodingPath, outputPath, { overwrite: true })
246 253
@@ -299,7 +306,7 @@ async function generateHlsPlaylistCommon (options: {
299 job 306 job
300 } 307 }
301 308
302 await transcode(transcodeOptions) 309 await transcodeVOD(transcodeOptions)
303 310
304 // Create or update the playlist 311 // Create or update the playlist
305 const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video) 312 const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
@@ -344,8 +351,8 @@ async function generateHlsPlaylistCommon (options: {
344 const stats = await stat(videoFilePath) 351 const stats = await stat(videoFilePath)
345 352
346 newVideoFile.size = stats.size 353 newVideoFile.size = stats.size
347 newVideoFile.fps = await getVideoFileFPS(videoFilePath) 354 newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
348 newVideoFile.metadata = await getMetadataFromFile(videoFilePath) 355 newVideoFile.metadata = await buildFileMetadata(videoFilePath)
349 356
350 await createTorrentAndSetInfoHash(playlist, newVideoFile) 357 await createTorrentAndSetInfoHash(playlist, newVideoFile)
351 358
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'
19import { Redis } from './redis' 19import { Redis } from './redis'
20import { createLocalVideoChannel } from './video-channel' 20import { createLocalVideoChannel } from './video-channel'
21import { createWatchLaterPlaylist } from './video-playlist' 21import { createWatchLaterPlaylist } from './video-playlist'
22import { logger } from '@server/helpers/logger'
22 23
23type ChannelNames = { name: string, displayName: string } 24type ChannelNames = { name: string, displayName: string }
24 25
@@ -159,6 +160,11 @@ async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
159 const uploadedTotal = newVideoSize + totalBytes 160 const uploadedTotal = newVideoSize + totalBytes
160 const uploadedDaily = newVideoSize + totalBytesDaily 161 const uploadedDaily = newVideoSize + totalBytesDaily
161 162
163 logger.debug(
164 'Check user %d quota to upload another video.', userId,
165 { totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, newVideoSize }
166 )
167
162 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota 168 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
163 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily 169 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
164 170
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 @@
1import { MVideoFullLight } from "@server/types/models"
2import { getVideoStreamDuration } from "@shared/extra-utils"
3import { VideoEditorTask } from "@shared/models"
4
5function buildTaskFileFieldname (indice: number, fieldName = 'file') {
6 return `tasks[${indice}][options][${fieldName}]`
7}
8
9function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') {
10 return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
11}
12
13async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoEditorTask[], fileFinder: (i: number) => string) {
14 let additionalDuration = 0
15
16 for (let i = 0; i < tasks.length; i++) {
17 const task = tasks[i]
18
19 if (task.name !== 'add-intro' && task.name !== 'add-outro') continue
20
21 const filePath = fileFinder(i)
22 additionalDuration += await getVideoStreamDuration(filePath)
23 }
24
25 return (video.getMaxQualityFile().size / video.duration) * additionalDuration
26}
27
28export {
29 approximateIntroOutroAdditionalSize,
30 buildTaskFileFieldname,
31 getTaskFile
32}
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: {
81 video.Tags = tagInstances 81 video.Tags = tagInstances
82} 82}
83 83
84async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId) { 84async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId, isNewVideo = true) {
85 let dataInput: VideoTranscodingPayload 85 let dataInput: VideoTranscodingPayload
86 86
87 if (videoFile.isAudio()) { 87 if (videoFile.isAudio()) {
@@ -90,13 +90,13 @@ async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoF
90 resolution: DEFAULT_AUDIO_RESOLUTION, 90 resolution: DEFAULT_AUDIO_RESOLUTION,
91 videoUUID: video.uuid, 91 videoUUID: video.uuid,
92 createHLSIfNeeded: true, 92 createHLSIfNeeded: true,
93 isNewVideo: true 93 isNewVideo
94 } 94 }
95 } else { 95 } else {
96 dataInput = { 96 dataInput = {
97 type: 'optimize-to-webtorrent', 97 type: 'optimize-to-webtorrent',
98 videoUUID: video.uuid, 98 videoUUID: video.uuid,
99 isNewVideo: true 99 isNewVideo
100 } 100 }
101 } 101 }
102 102
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 = [
57 body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'), 57 body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
58 body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'), 58 body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
59 59
60 body('videoEditor.enabled').isBoolean().withMessage('Should have a valid video editor enabled boolean'),
61
60 body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'), 62 body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'),
61 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'), 63 body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
62 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'), 64 body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
@@ -104,6 +106,7 @@ const customConfigUpdateValidator = [
104 if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return 106 if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
105 if (!checkInvalidTranscodingConfig(req.body, res)) return 107 if (!checkInvalidTranscodingConfig(req.body, res)) return
106 if (!checkInvalidLiveConfig(req.body, res)) return 108 if (!checkInvalidLiveConfig(req.body, res)) return
109 if (!checkInvalidVideoEditorConfig(req.body, res)) return
107 110
108 return next() 111 return next()
109 } 112 }
@@ -159,3 +162,14 @@ function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Respon
159 162
160 return true 163 return true
161} 164}
165
166function checkInvalidVideoEditorConfig (customConfig: CustomConfig, res: express.Response) {
167 if (customConfig.videoEditor.enabled === false) return true
168
169 if (customConfig.videoEditor.enabled === true && customConfig.transcoding.enabled === false) {
170 res.fail({ message: 'You cannot enable video editor if transcoding is not enabled' })
171 return false
172 }
173
174 return true
175}
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) {
8 8
9 if (!errors.isEmpty()) { 9 if (!errors.isEmpty()) {
10 logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() }) 10 logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
11
11 res.fail({ 12 res.fail({
12 message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '), 13 message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
13 instance: req.originalUrl, 14 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 @@
1import { Request, Response } from 'express' 1import { Request, Response } from 'express'
2import { loadVideo, VideoLoadType } from '@server/lib/model-loaders' 2import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
3import { isAbleToUploadVideo } from '@server/lib/user'
3import { authenticatePromiseIfNeeded } from '@server/middlewares/auth' 4import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
4import { VideoModel } from '@server/models/video/video' 5import { VideoModel } from '@server/models/video/video'
5import { VideoChannelModel } from '@server/models/video/video-channel' 6import { VideoChannelModel } from '@server/models/video/video-channel'
@@ -7,6 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file'
7import { 8import {
8 MUser, 9 MUser,
9 MUserAccountId, 10 MUserAccountId,
11 MUserId,
10 MVideo, 12 MVideo,
11 MVideoAccountLight, 13 MVideoAccountLight,
12 MVideoFormattableDetails, 14 MVideoFormattableDetails,
@@ -16,7 +18,7 @@ import {
16 MVideoThumbnail, 18 MVideoThumbnail,
17 MVideoWithRights 19 MVideoWithRights
18} from '@server/types/models' 20} from '@server/types/models'
19import { HttpStatusCode, UserRight } from '@shared/models' 21import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models'
20 22
21async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { 23async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
22 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 24 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
108 110
109 // Only the owner or a user that have blocklist rights can see the video 111 // Only the owner or a user that have blocklist rights can see the video
110 if (!user || !user.canGetVideo(video)) { 112 if (!user || !user.canGetVideo(video)) {
113 res.fail({
114 status: HttpStatusCode.FORBIDDEN_403,
115 message: 'Cannot fetch information of private/internal/blocklisted video'
116 })
117
111 return false 118 return false
112 } 119 }
113 120
@@ -139,13 +146,28 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
139 return true 146 return true
140} 147}
141 148
149async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) {
150 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
151 res.fail({
152 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
153 message: 'The user video quota is exceeded with this video.',
154 type: ServerErrorCode.QUOTA_REACHED
155 })
156 return false
157 }
158
159 return true
160}
161
142// --------------------------------------------------------------------------- 162// ---------------------------------------------------------------------------
143 163
144export { 164export {
145 doesVideoChannelOfAccountExist, 165 doesVideoChannelOfAccountExist,
146 doesVideoExist, 166 doesVideoExist,
147 doesVideoFileOfVideoExist, 167 doesVideoFileOfVideoExist,
168
148 checkUserCanManageVideo, 169 checkUserCanManageVideo,
149 checkCanSeeVideoIfPrivate, 170 checkCanSeeVideoIfPrivate,
150 checkCanSeePrivateVideo 171 checkCanSeePrivateVideo,
172 checkUserQuota
151} 173}
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'
2export * from './video-captions' 2export * from './video-captions'
3export * from './video-channels' 3export * from './video-channels'
4export * from './video-comments' 4export * from './video-comments'
5export * from './video-editor'
5export * from './video-files' 6export * from './video-files'
6export * from './video-imports' 7export * from './video-imports'
7export * from './video-live' 8export * 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 @@
1import express from 'express' 1import express from 'express'
2import { body, param } from 'express-validator' 2import { body, param } from 'express-validator'
3import { HttpStatusCode, UserRight } from '@shared/models' 3import { UserRight } from '@shared/models'
4import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' 4import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
5import { cleanUpReqFiles } from '../../../helpers/express-utils' 5import { cleanUpReqFiles } from '../../../helpers/express-utils'
6import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
@@ -74,13 +74,7 @@ const listVideoCaptionsValidator = [
74 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 74 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
75 75
76 const video = res.locals.onlyVideo 76 const video = res.locals.onlyVideo
77 77 if (!await checkCanSeeVideoIfPrivate(req, res, video)) return
78 if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
79 return res.fail({
80 status: HttpStatusCode.FORBIDDEN_403,
81 message: 'Cannot list captions of private/internal/blocklisted video'
82 })
83 }
84 78
85 return next() 79 return next()
86 } 80 }
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 = [
54 if (areValidationErrors(req, res)) return 54 if (areValidationErrors(req, res)) return
55 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 55 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
56 56
57 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) { 57 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
58 return res.fail({
59 status: HttpStatusCode.FORBIDDEN_403,
60 message: 'Cannot list comments of private/internal/blocklisted video'
61 })
62 }
63 58
64 return next() 59 return next()
65 } 60 }
@@ -78,12 +73,7 @@ const listVideoThreadCommentsValidator = [
78 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 73 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
79 if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return 74 if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
80 75
81 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) { 76 if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
82 return res.fail({
83 status: HttpStatusCode.FORBIDDEN_403,
84 message: 'Cannot list threads of private/internal/blocklisted video'
85 })
86 }
87 77
88 return next() 78 return next()
89 } 79 }
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 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc'
4import {
5 isEditorCutTaskValid,
6 isEditorTaskAddIntroOutroValid,
7 isEditorTaskAddWatermarkValid,
8 isValidEditorTasksArray
9} from '@server/helpers/custom-validators/video-editor'
10import { cleanUpReqFiles } from '@server/helpers/express-utils'
11import { CONFIG } from '@server/initializers/config'
12import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-editor'
13import { isAudioFile } from '@shared/extra-utils'
14import { HttpStatusCode, UserRight, VideoEditorCreateEdition, VideoEditorTask, VideoState } from '@shared/models'
15import { logger } from '../../../helpers/logger'
16import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
17
18const videosEditorAddEditionValidator = [
19 param('videoId').custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
20
21 body('tasks').custom(isValidEditorTasksArray).withMessage('Should have a valid array of tasks'),
22
23 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
24 logger.debug('Checking videosEditorAddEditionValidator parameters.', { parameters: req.params, body: req.body, files: req.files })
25
26 if (CONFIG.VIDEO_EDITOR.ENABLED !== true) {
27 res.fail({
28 status: HttpStatusCode.BAD_REQUEST_400,
29 message: 'Video editor is disabled on this instance'
30 })
31
32 return cleanUpReqFiles(req)
33 }
34
35 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
36
37 const body: VideoEditorCreateEdition = req.body
38 const files = req.files as Express.Multer.File[]
39
40 for (let i = 0; i < body.tasks.length; i++) {
41 const task = body.tasks[i]
42
43 if (!checkTask(req, task, i)) {
44 res.fail({
45 status: HttpStatusCode.BAD_REQUEST_400,
46 message: `Task ${task.name} is invalid`
47 })
48
49 return cleanUpReqFiles(req)
50 }
51
52 if (task.name === 'add-intro' || task.name === 'add-outro') {
53 const filePath = getTaskFile(files, i).path
54
55 // Our concat filter needs a video stream
56 if (await isAudioFile(filePath)) {
57 res.fail({
58 status: HttpStatusCode.BAD_REQUEST_400,
59 message: `Task ${task.name} is invalid: file does not contain a video stream`
60 })
61
62 return cleanUpReqFiles(req)
63 }
64 }
65 }
66
67 if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
68
69 const video = res.locals.videoAll
70 if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
71 res.fail({
72 status: HttpStatusCode.CONFLICT_409,
73 message: 'Cannot edit video that is already waiting for transcoding/edition'
74 })
75
76 return cleanUpReqFiles(req)
77 }
78
79 const user = res.locals.oauth.token.User
80 if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
81
82 // Try to make an approximation of bytes added by the intro/outro
83 const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFile(files, i).path)
84 if (await checkUserQuota(user, additionalBytes, res) === false) return cleanUpReqFiles(req)
85
86 return next()
87 }
88]
89
90// ---------------------------------------------------------------------------
91
92export {
93 videosEditorAddEditionValidator
94}
95
96// ---------------------------------------------------------------------------
97
98const taskCheckers: {
99 [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => boolean
100} = {
101 'cut': isEditorCutTaskValid,
102 'add-intro': isEditorTaskAddIntroOutroValid,
103 'add-outro': isEditorTaskAddIntroOutroValid,
104 'add-watermark': isEditorTaskAddWatermarkValid
105}
106
107function checkTask (req: express.Request, task: VideoEditorTask, indice?: number) {
108 const checker = taskCheckers[task.name]
109 if (!checker) return false
110
111 return checker(task, indice, req.files as Express.Multer.File[])
112}
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'
3import { isIdValid } from '@server/helpers/custom-validators/misc' 3import { isIdValid } from '@server/helpers/custom-validators/misc'
4import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership' 4import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
5import { logger } from '@server/helpers/logger' 5import { logger } from '@server/helpers/logger'
6import { isAbleToUploadVideo } from '@server/lib/user'
7import { AccountModel } from '@server/models/account/account' 6import { AccountModel } from '@server/models/account/account'
8import { MVideoWithAllFiles } from '@server/types/models' 7import { MVideoWithAllFiles } from '@server/types/models'
9import { 8import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models'
10 HttpStatusCode,
11 ServerErrorCode,
12 UserRight,
13 VideoChangeOwnershipAccept,
14 VideoChangeOwnershipStatus,
15 VideoState
16} from '@shared/models'
17import { 9import {
18 areValidationErrors, 10 areValidationErrors,
19 checkUserCanManageVideo, 11 checkUserCanManageVideo,
12 checkUserQuota,
20 doesChangeVideoOwnershipExist, 13 doesChangeVideoOwnershipExist,
21 doesVideoChannelOfAccountExist, 14 doesVideoChannelOfAccountExist,
22 doesVideoExist, 15 doesVideoExist,
@@ -113,15 +106,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response)
113 106
114 const user = res.locals.oauth.token.User 107 const user = res.locals.oauth.token.User
115 108
116 if (!await isAbleToUploadVideo(user.id, video.getMaxQualityFile().size)) { 109 if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false
117 res.fail({
118 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
119 message: 'The user video quota is exceeded with this video.',
120 type: ServerErrorCode.QUOTA_REACHED
121 })
122
123 return false
124 }
125 110
126 return true 111 return true
127} 112}
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 {
27 isVideoPlaylistTimestampValid, 27 isVideoPlaylistTimestampValid,
28 isVideoPlaylistTypeValid 28 isVideoPlaylistTypeValid
29} from '../../../helpers/custom-validators/video-playlists' 29} from '../../../helpers/custom-validators/video-playlists'
30import { isVideoImage } from '../../../helpers/custom-validators/videos' 30import { isVideoImageValid } from '../../../helpers/custom-validators/videos'
31import { cleanUpReqFiles } from '../../../helpers/express-utils' 31import { cleanUpReqFiles } from '../../../helpers/express-utils'
32import { logger } from '../../../helpers/logger' 32import { logger } from '../../../helpers/logger'
33import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' 33import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
@@ -390,7 +390,7 @@ export {
390function getCommonPlaylistEditAttributes () { 390function getCommonPlaylistEditAttributes () {
391 return [ 391 return [
392 body('thumbnailfile') 392 body('thumbnailfile')
393 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')) 393 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
394 .withMessage( 394 .withMessage(
395 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' + 395 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
396 CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ') 396 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'
3import { isTestInstance } from '@server/helpers/core-utils' 3import { isTestInstance } from '@server/helpers/core-utils'
4import { getResumableUploadPath } from '@server/helpers/upload' 4import { getResumableUploadPath } from '@server/helpers/upload'
5import { Redis } from '@server/lib/redis' 5import { Redis } from '@server/lib/redis'
6import { isAbleToUploadVideo } from '@server/lib/user'
7import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
8import { ExpressPromiseHandler } from '@server/types/express-handler' 7import { ExpressPromiseHandler } from '@server/types/express-handler'
9import { MUserAccountId, MVideoFullLight } from '@server/types/models' 8import { MUserAccountId, MVideoFullLight } from '@server/types/models'
@@ -13,7 +12,7 @@ import {
13 exists, 12 exists,
14 isBooleanValid, 13 isBooleanValid,
15 isDateValid, 14 isDateValid,
16 isFileFieldValid, 15 isFileValid,
17 isIdValid, 16 isIdValid,
18 isUUIDValid, 17 isUUIDValid,
19 toArray, 18 toArray,
@@ -23,24 +22,24 @@ import {
23} from '../../../helpers/custom-validators/misc' 22} from '../../../helpers/custom-validators/misc'
24import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' 23import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
25import { 24import {
25 areVideoTagsValid,
26 isScheduleVideoUpdatePrivacyValid, 26 isScheduleVideoUpdatePrivacyValid,
27 isVideoCategoryValid, 27 isVideoCategoryValid,
28 isVideoDescriptionValid, 28 isVideoDescriptionValid,
29 isVideoFileMimeTypeValid, 29 isVideoFileMimeTypeValid,
30 isVideoFileSizeValid, 30 isVideoFileSizeValid,
31 isVideoFilterValid, 31 isVideoFilterValid,
32 isVideoImage, 32 isVideoImageValid,
33 isVideoIncludeValid, 33 isVideoIncludeValid,
34 isVideoLanguageValid, 34 isVideoLanguageValid,
35 isVideoLicenceValid, 35 isVideoLicenceValid,
36 isVideoNameValid, 36 isVideoNameValid,
37 isVideoOriginallyPublishedAtValid, 37 isVideoOriginallyPublishedAtValid,
38 isVideoPrivacyValid, 38 isVideoPrivacyValid,
39 isVideoSupportValid, 39 isVideoSupportValid
40 isVideoTagsValid
41} from '../../../helpers/custom-validators/videos' 40} from '../../../helpers/custom-validators/videos'
42import { cleanUpReqFiles } from '../../../helpers/express-utils' 41import { cleanUpReqFiles } from '../../../helpers/express-utils'
43import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils' 42import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
44import { logger } from '../../../helpers/logger' 43import { logger } from '../../../helpers/logger'
45import { deleteFileAndCatch } from '../../../helpers/utils' 44import { deleteFileAndCatch } from '../../../helpers/utils'
46import { getVideoWithAttributes } from '../../../helpers/video' 45import { getVideoWithAttributes } from '../../../helpers/video'
@@ -53,6 +52,7 @@ import {
53 areValidationErrors, 52 areValidationErrors,
54 checkCanSeePrivateVideo, 53 checkCanSeePrivateVideo,
55 checkUserCanManageVideo, 54 checkUserCanManageVideo,
55 checkUserQuota,
56 doesVideoChannelOfAccountExist, 56 doesVideoChannelOfAccountExist,
57 doesVideoExist, 57 doesVideoExist,
58 doesVideoFileOfVideoExist, 58 doesVideoFileOfVideoExist,
@@ -61,7 +61,7 @@ import {
61 61
62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
63 body('videofile') 63 body('videofile')
64 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile')) 64 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
65 .withMessage('Should have a file'), 65 .withMessage('Should have a file'),
66 body('name') 66 body('name')
67 .trim() 67 .trim()
@@ -299,12 +299,11 @@ const videosCustomGetValidator = (
299 299
300 // Video private or blacklisted 300 // Video private or blacklisted
301 if (video.requiresAuth()) { 301 if (video.requiresAuth()) {
302 if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next() 302 if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) {
303 return next()
304 }
303 305
304 return res.fail({ 306 return
305 status: HttpStatusCode.FORBIDDEN_403,
306 message: 'Cannot get this private/internal or blocklisted video'
307 })
308 } 307 }
309 308
310 // Video is public, anyone can access it 309 // Video is public, anyone can access it
@@ -375,12 +374,12 @@ const videosOverviewValidator = [
375function getCommonVideoEditAttributes () { 374function getCommonVideoEditAttributes () {
376 return [ 375 return [
377 body('thumbnailfile') 376 body('thumbnailfile')
378 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage( 377 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
379 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' + 378 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
380 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 379 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
381 ), 380 ),
382 body('previewfile') 381 body('previewfile')
383 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage( 382 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
384 'This preview file is not supported or too large. Please, make sure it is of the following type: ' + 383 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
385 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ') 384 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
386 ), 385 ),
@@ -420,7 +419,7 @@ function getCommonVideoEditAttributes () {
420 body('tags') 419 body('tags')
421 .optional() 420 .optional()
422 .customSanitizer(toValueOrNull) 421 .customSanitizer(toValueOrNull)
423 .custom(isVideoTagsValid) 422 .custom(areVideoTagsValid)
424 .withMessage( 423 .withMessage(
425 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` + 424 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
426 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each` 425 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
@@ -612,14 +611,7 @@ async function commonVideoChecksPass (parameters: {
612 return false 611 return false
613 } 612 }
614 613
615 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) { 614 if (await checkUserQuota(user, videoFileSize, res) === false) return false
616 res.fail({
617 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
618 message: 'The user video quota is exceeded with this video.',
619 type: ServerErrorCode.QUOTA_REACHED
620 })
621 return false
622 }
623 615
624 return true 616 return true
625} 617}
@@ -654,7 +646,7 @@ export async function isVideoAccepted (
654} 646}
655 647
656async function addDurationToVideo (videoFile: { path: string, duration?: number }) { 648async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
657 const duration: number = await getDurationFromVideoFile(videoFile.path) 649 const duration: number = await getVideoStreamDuration(videoFile.path)
658 650
659 if (isNaN(duration)) throw new Error(`Couldn't get video duration`) 651 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
660 652
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 {
61 isVideoStateValid, 61 isVideoStateValid,
62 isVideoSupportValid 62 isVideoSupportValid
63} from '../../helpers/custom-validators/videos' 63} from '../../helpers/custom-validators/videos'
64import { getVideoFileResolution } from '../../helpers/ffprobe-utils' 64import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
65import { logger } from '../../helpers/logger' 65import { logger } from '../../helpers/logger'
66import { CONFIG } from '../../initializers/config' 66import { CONFIG } from '../../initializers/config'
67import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' 67import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
@@ -1683,7 +1683,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1683 return peertubeTruncate(this.description, { length: maxLength }) 1683 return peertubeTruncate(this.description, { length: maxLength })
1684 } 1684 }
1685 1685
1686 getMaxQualityFileInfo () { 1686 probeMaxQualityFile () {
1687 const file = this.getMaxQualityFile() 1687 const file = this.getMaxQualityFile()
1688 const videoOrPlaylist = file.getVideoOrStreamingPlaylist() 1688 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1689 1689
@@ -1695,7 +1695,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1695 return { 1695 return {
1696 audioStream, 1696 audioStream,
1697 1697
1698 ...await getVideoFileResolution(originalFilePath, probe) 1698 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1699 } 1699 }
1700 }) 1700 })
1701 } 1701 }
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 () {
25 before(async function () { 25 before(async function () {
26 this.timeout(60000) 26 this.timeout(60000)
27 27
28 servers = await createMultipleServers(2, { transcoding: { enabled: false } }) 28 servers = await createMultipleServers(2)
29 29
30 // Get the access tokens 30 // Get the access tokens
31 await setAccessTokensToServers(servers) 31 await setAccessTokensToServers(servers)
32 await setDefaultVideoChannel(servers) 32 await setDefaultVideoChannel(servers)
33 33
34 for (const server of servers) {
35 await server.config.disableTranscoding()
36 }
37
34 { 38 {
35 videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid 39 videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid
36 videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid 40 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 () {
145 } 145 }
146 } 146 }
147 }, 147 },
148 videoEditor: {
149 enabled: true
150 },
148 import: { 151 import: {
149 videos: { 152 videos: {
150 concurrency: 1, 153 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'
25import './video-captions' 25import './video-captions'
26import './video-channels' 26import './video-channels'
27import './video-comments' 27import './video-comments'
28import './video-editor'
28import './video-imports' 29import './video-imports'
29import './video-playlists' 30import './video-playlists'
30import './videos' 31import './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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { HttpStatusCode, VideoEditorTask } from '@shared/models'
5import {
6 cleanupTests,
7 createSingleServer,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 VideoEditorCommand,
11 waitJobs
12} from '@shared/server-commands'
13
14describe('Test video editor API validator', function () {
15 let server: PeerTubeServer
16 let command: VideoEditorCommand
17 let userAccessToken: string
18 let videoUUID: string
19
20 // ---------------------------------------------------------------
21
22 before(async function () {
23 this.timeout(120_000)
24
25 server = await createSingleServer(1)
26
27 await setAccessTokensToServers([ server ])
28 userAccessToken = await server.users.generateUserAndToken('user1')
29
30 await server.config.enableMinimumTranscoding()
31
32 const { uuid } = await server.videos.quickUpload({ name: 'video' })
33 videoUUID = uuid
34
35 command = server.videoEditor
36
37 await waitJobs([ server ])
38 })
39
40 describe('Task creation', function () {
41
42 describe('Config settings', function () {
43
44 it('Should fail if editor is disabled', async function () {
45 await server.config.updateExistingSubConfig({
46 newConfig: {
47 videoEditor: {
48 enabled: false
49 }
50 }
51 })
52
53 await command.createEditionTasks({
54 videoId: videoUUID,
55 tasks: VideoEditorCommand.getComplexTask(),
56 expectedStatus: HttpStatusCode.BAD_REQUEST_400
57 })
58 })
59
60 it('Should fail to enable editor if transcoding is disabled', async function () {
61 await server.config.updateExistingSubConfig({
62 newConfig: {
63 videoEditor: {
64 enabled: true
65 },
66 transcoding: {
67 enabled: false
68 }
69 },
70 expectedStatus: HttpStatusCode.BAD_REQUEST_400
71 })
72 })
73
74 it('Should succeed to enable video editor', async function () {
75 await server.config.updateExistingSubConfig({
76 newConfig: {
77 videoEditor: {
78 enabled: true
79 },
80 transcoding: {
81 enabled: true
82 }
83 }
84 })
85 })
86 })
87
88 describe('Common tasks', function () {
89
90 it('Should fail without token', async function () {
91 await command.createEditionTasks({
92 token: null,
93 videoId: videoUUID,
94 tasks: VideoEditorCommand.getComplexTask(),
95 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
96 })
97 })
98
99 it('Should fail with another user token', async function () {
100 await command.createEditionTasks({
101 token: userAccessToken,
102 videoId: videoUUID,
103 tasks: VideoEditorCommand.getComplexTask(),
104 expectedStatus: HttpStatusCode.FORBIDDEN_403
105 })
106 })
107
108 it('Should fail with an invalid video', async function () {
109 await command.createEditionTasks({
110 videoId: 'tintin',
111 tasks: VideoEditorCommand.getComplexTask(),
112 expectedStatus: HttpStatusCode.BAD_REQUEST_400
113 })
114 })
115
116 it('Should fail with an unknown video', async function () {
117 await command.createEditionTasks({
118 videoId: 42,
119 tasks: VideoEditorCommand.getComplexTask(),
120 expectedStatus: HttpStatusCode.NOT_FOUND_404
121 })
122 })
123
124 it('Should fail with an already in transcoding state video', async function () {
125 await server.jobs.pauseJobQueue()
126
127 const { uuid } = await server.videos.quickUpload({ name: 'transcoded video' })
128
129 await command.createEditionTasks({
130 videoId: uuid,
131 tasks: VideoEditorCommand.getComplexTask(),
132 expectedStatus: HttpStatusCode.CONFLICT_409
133 })
134
135 await server.jobs.resumeJobQueue()
136 })
137
138 it('Should fail with a bad complex task', async function () {
139 await command.createEditionTasks({
140 videoId: videoUUID,
141 tasks: [
142 {
143 name: 'cut',
144 options: {
145 start: 1,
146 end: 2
147 }
148 },
149 {
150 name: 'hadock',
151 options: {
152 start: 1,
153 end: 2
154 }
155 }
156 ] as any,
157 expectedStatus: HttpStatusCode.BAD_REQUEST_400
158 })
159 })
160
161 it('Should fail without task', async function () {
162 await command.createEditionTasks({
163 videoId: videoUUID,
164 tasks: [],
165 expectedStatus: HttpStatusCode.BAD_REQUEST_400
166 })
167 })
168
169 it('Should fail with too many tasks', async function () {
170 const tasks: VideoEditorTask[] = []
171
172 for (let i = 0; i < 110; i++) {
173 tasks.push({
174 name: 'cut',
175 options: {
176 start: 1
177 }
178 })
179 }
180
181 await command.createEditionTasks({
182 videoId: videoUUID,
183 tasks,
184 expectedStatus: HttpStatusCode.BAD_REQUEST_400
185 })
186 })
187
188 it('Should succeed with correct parameters', async function () {
189 await server.jobs.pauseJobQueue()
190
191 await command.createEditionTasks({
192 videoId: videoUUID,
193 tasks: VideoEditorCommand.getComplexTask(),
194 expectedStatus: HttpStatusCode.NO_CONTENT_204
195 })
196 })
197
198 it('Should fail with a video that is already waiting for edition', async function () {
199 this.timeout(120000)
200
201 await command.createEditionTasks({
202 videoId: videoUUID,
203 tasks: VideoEditorCommand.getComplexTask(),
204 expectedStatus: HttpStatusCode.CONFLICT_409
205 })
206
207 await server.jobs.resumeJobQueue()
208
209 await waitJobs([ server ])
210 })
211 })
212
213 describe('Cut task', function () {
214
215 async function cut (start: number, end: number, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
216 await command.createEditionTasks({
217 videoId: videoUUID,
218 tasks: [
219 {
220 name: 'cut',
221 options: {
222 start,
223 end
224 }
225 }
226 ],
227 expectedStatus
228 })
229 }
230
231 it('Should fail with bad start/end', async function () {
232 const invalid = [
233 'tintin',
234 -1,
235 undefined
236 ]
237
238 for (const value of invalid) {
239 await cut(value as any, undefined)
240 await cut(undefined, value as any)
241 }
242 })
243
244 it('Should fail with the same start/end', async function () {
245 await cut(2, 2)
246 })
247
248 it('Should fail with inconsistents start/end', async function () {
249 await cut(2, 1)
250 })
251
252 it('Should fail without start and end', async function () {
253 await cut(undefined, undefined)
254 })
255
256 it('Should succeed with the correct params', async function () {
257 this.timeout(120000)
258
259 await cut(0, 2, HttpStatusCode.NO_CONTENT_204)
260
261 await waitJobs([ server ])
262 })
263 })
264
265 describe('Watermark task', function () {
266
267 async function addWatermark (file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
268 await command.createEditionTasks({
269 videoId: videoUUID,
270 tasks: [
271 {
272 name: 'add-watermark',
273 options: {
274 file
275 }
276 }
277 ],
278 expectedStatus
279 })
280 }
281
282 it('Should fail without waterkmark', async function () {
283 await addWatermark(undefined)
284 })
285
286 it('Should fail with an invalid watermark', async function () {
287 await addWatermark('video_short.mp4')
288 })
289
290 it('Should succeed with the correct params', async function () {
291 this.timeout(120000)
292
293 await addWatermark('thumbnail.jpg', HttpStatusCode.NO_CONTENT_204)
294
295 await waitJobs([ server ])
296 })
297 })
298
299 describe('Intro/Outro task', function () {
300
301 async function addIntroOutro (type: 'add-intro' | 'add-outro', file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
302 await command.createEditionTasks({
303 videoId: videoUUID,
304 tasks: [
305 {
306 name: type,
307 options: {
308 file
309 }
310 }
311 ],
312 expectedStatus
313 })
314 }
315
316 it('Should fail without file', async function () {
317 await addIntroOutro('add-intro', undefined)
318 await addIntroOutro('add-outro', undefined)
319 })
320
321 it('Should fail with an invalid file', async function () {
322 await addIntroOutro('add-intro', 'thumbnail.jpg')
323 await addIntroOutro('add-outro', 'thumbnail.jpg')
324 })
325
326 it('Should fail with a file that does not contain video stream', async function () {
327 await addIntroOutro('add-intro', 'sample.ogg')
328 await addIntroOutro('add-outro', 'sample.ogg')
329
330 })
331
332 it('Should succeed with the correct params', async function () {
333 this.timeout(120000)
334
335 await addIntroOutro('add-intro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
336 await waitJobs([ server ])
337
338 await addIntroOutro('add-outro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
339 await waitJobs([ server ])
340 })
341
342 it('Should check total quota when creating the task', async function () {
343 this.timeout(120000)
344
345 const user = await server.users.create({ username: 'user_quota_1' })
346 const token = await server.login.getAccessToken('user_quota_1')
347 const { uuid } = await server.videos.quickUpload({ token, name: 'video_quota_1', fixture: 'video_short.mp4' })
348
349 const addIntroOutroByUser = (type: 'add-intro' | 'add-outro', expectedStatus: HttpStatusCode) => {
350 return command.createEditionTasks({
351 token,
352 videoId: uuid,
353 tasks: [
354 {
355 name: type,
356 options: {
357 file: 'video_short.mp4'
358 }
359 }
360 ],
361 expectedStatus
362 })
363 }
364
365 await waitJobs([ server ])
366
367 const { videoQuotaUsed } = await server.users.getMyQuotaUsed({ token })
368 await server.users.update({ userId: user.id, videoQuota: Math.round(videoQuotaUsed * 2.5) })
369
370 // Still valid
371 await addIntroOutroByUser('add-intro', HttpStatusCode.NO_CONTENT_204)
372
373 await waitJobs([ server ])
374
375 // Too much quota
376 await addIntroOutroByUser('add-intro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
377 await addIntroOutroByUser('add-outro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
378 })
379 })
380 })
381
382 after(async function () {
383 await cleanupTests([ server ])
384 })
385})
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 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { basename, join } from 'path' 5import { basename, join } from 'path'
6import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' 6import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
7import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared' 7import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
8import { wait } from '@shared/core-utils' 8import { wait } from '@shared/core-utils'
9import { 9import {
@@ -562,7 +562,7 @@ describe('Test live', function () {
562 const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename)) 562 const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
563 563
564 const probe = await ffprobePromise(segmentPath) 564 const probe = await ffprobePromise(segmentPath)
565 const videoStream = await getVideoStreamFromFile(segmentPath, probe) 565 const videoStream = await getVideoStream(segmentPath, probe)
566 566
567 expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height]) 567 expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
568 expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height]) 568 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 () {
26 26
27 const servers = await Promise.all([ 27 const servers = await Promise.all([
28 createSingleServer(1), 28 createSingleServer(1),
29 createSingleServer(2, { transcoding: { enabled: false } }) 29 createSingleServer(2)
30 ]) 30 ])
31 server = servers[0] 31 server = servers[0]
32 remoteServer = servers[1] 32 remoteServer = servers[1]
@@ -35,6 +35,8 @@ describe('Test channels search', function () {
35 await setDefaultChannelAvatar(server) 35 await setDefaultChannelAvatar(server)
36 await setDefaultAccountAvatar(server) 36 await setDefaultAccountAvatar(server)
37 37
38 await servers[1].config.disableTranscoding()
39
38 { 40 {
39 await server.users.create({ username: 'user1' }) 41 await server.users.create({ username: 'user1' })
40 const channel = { 42 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 () {
29 29
30 const servers = await Promise.all([ 30 const servers = await Promise.all([
31 createSingleServer(1), 31 createSingleServer(1),
32 createSingleServer(2, { transcoding: { enabled: false } }) 32 createSingleServer(2)
33 ]) 33 ])
34 server = servers[0] 34 server = servers[0]
35 remoteServer = servers[1] 35 remoteServer = servers[1]
@@ -39,6 +39,8 @@ describe('Test playlists search', function () {
39 await setDefaultChannelAvatar([ remoteServer, server ]) 39 await setDefaultChannelAvatar([ remoteServer, server ])
40 await setDefaultAccountAvatar([ remoteServer, server ]) 40 await setDefaultAccountAvatar([ remoteServer, server ])
41 41
42 await servers[1].config.disableTranscoding()
43
42 { 44 {
43 const videoId = (await server.videos.upload()).uuid 45 const videoId = (await server.videos.upload()).uuid
44 46
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) {
97 expect(data.live.transcoding.resolutions['1440p']).to.be.false 97 expect(data.live.transcoding.resolutions['1440p']).to.be.false
98 expect(data.live.transcoding.resolutions['2160p']).to.be.false 98 expect(data.live.transcoding.resolutions['2160p']).to.be.false
99 99
100 expect(data.videoEditor.enabled).to.be.false
101
100 expect(data.import.videos.concurrency).to.equal(2) 102 expect(data.import.videos.concurrency).to.equal(2)
101 expect(data.import.videos.http.enabled).to.be.true 103 expect(data.import.videos.http.enabled).to.be.true
102 expect(data.import.videos.torrent.enabled).to.be.true 104 expect(data.import.videos.torrent.enabled).to.be.true
@@ -197,6 +199,8 @@ function checkUpdatedConfig (data: CustomConfig) {
197 expect(data.live.transcoding.resolutions['1080p']).to.be.true 199 expect(data.live.transcoding.resolutions['1080p']).to.be.true
198 expect(data.live.transcoding.resolutions['2160p']).to.be.true 200 expect(data.live.transcoding.resolutions['2160p']).to.be.true
199 201
202 expect(data.videoEditor.enabled).to.be.true
203
200 expect(data.import.videos.concurrency).to.equal(4) 204 expect(data.import.videos.concurrency).to.equal(4)
201 expect(data.import.videos.http.enabled).to.be.false 205 expect(data.import.videos.http.enabled).to.be.false
202 expect(data.import.videos.torrent.enabled).to.be.false 206 expect(data.import.videos.torrent.enabled).to.be.false
@@ -341,6 +345,9 @@ const newCustomConfig: CustomConfig = {
341 } 345 }
342 } 346 }
343 }, 347 },
348 videoEditor: {
349 enabled: true
350 },
344 import: { 351 import: {
345 videos: { 352 videos: {
346 concurrency: 4, 353 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 () {
230 it('Should have the correct AP stats', async function () { 230 it('Should have the correct AP stats', async function () {
231 this.timeout(60000) 231 this.timeout(60000)
232 232
233 await servers[0].config.updateCustomSubConfig({ 233 await servers[0].config.disableTranscoding()
234 newConfig: {
235 transcoding: {
236 enabled: false
237 }
238 }
239 })
240 234
241 const first = await servers[1].stats.get() 235 const first = await servers[1].stats.get()
242 236
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 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { getAudioStream, getVideoStreamSize } from '@server/helpers/ffprobe-utils' 5import { getAudioStream, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createMultipleServers, 8 createMultipleServers,
@@ -91,9 +91,8 @@ describe('Test audio only video transcoding', function () {
91 expect(audioStream['codec_name']).to.be.equal('aac') 91 expect(audioStream['codec_name']).to.be.equal('aac')
92 expect(audioStream['bit_rate']).to.be.at.most(384 * 8000) 92 expect(audioStream['bit_rate']).to.be.at.most(384 * 8000)
93 93
94 const size = await getVideoStreamSize(path) 94 const size = await getVideoStreamDimensionsInfo(path)
95 expect(size.height).to.equal(0) 95 expect(size).to.not.exist
96 expect(size.width).to.equal(0)
97 } 96 }
98 }) 97 })
99 98
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'
8import './video-comments' 8import './video-comments'
9import './video-create-transcoding' 9import './video-create-transcoding'
10import './video-description' 10import './video-description'
11import './video-editor'
11import './video-files' 12import './video-files'
12import './video-hls' 13import './video-hls'
13import './video-imports' 14import './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 @@
1import { expect } from 'chai'
2import { expectStartWith, getAllFiles } from '@server/tests/shared'
3import { areObjectStorageTestsDisabled } from '@shared/core-utils'
4import { VideoEditorTask } from '@shared/models'
5import {
6 cleanupTests,
7 createMultipleServers,
8 doubleFollow,
9 ObjectStorageCommand,
10 PeerTubeServer,
11 setAccessTokensToServers,
12 setDefaultVideoChannel,
13 VideoEditorCommand,
14 waitJobs
15} from '@shared/server-commands'
16
17describe('Test video editor', function () {
18 let servers: PeerTubeServer[] = []
19 let videoUUID: string
20
21 async function checkDuration (server: PeerTubeServer, duration: number) {
22 const video = await server.videos.get({ id: videoUUID })
23
24 expect(video.duration).to.be.approximately(duration, 1)
25
26 for (const file of video.files) {
27 const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
28
29 for (const stream of metadata.streams) {
30 expect(Math.round(stream.duration)).to.be.approximately(duration, 1)
31 }
32 }
33 }
34
35 async function renewVideo (fixture = 'video_short.webm') {
36 const video = await servers[0].videos.quickUpload({ name: 'video', fixture })
37 videoUUID = video.uuid
38
39 await waitJobs(servers)
40 }
41
42 async function createTasks (tasks: VideoEditorTask[]) {
43 await servers[0].videoEditor.createEditionTasks({ videoId: videoUUID, tasks })
44 await waitJobs(servers)
45 }
46
47 before(async function () {
48 this.timeout(120_000)
49
50 servers = await createMultipleServers(2)
51
52 await setAccessTokensToServers(servers)
53 await setDefaultVideoChannel(servers)
54
55 await doubleFollow(servers[0], servers[1])
56
57 await servers[0].config.enableMinimumTranscoding()
58
59 await servers[0].config.updateExistingSubConfig({
60 newConfig: {
61 videoEditor: {
62 enabled: true
63 }
64 }
65 })
66 })
67
68 describe('Cutting', function () {
69
70 it('Should cut the beginning of the video', async function () {
71 this.timeout(120_000)
72
73 await renewVideo()
74 await waitJobs(servers)
75
76 const beforeTasks = new Date()
77
78 await createTasks([
79 {
80 name: 'cut',
81 options: {
82 start: 2
83 }
84 }
85 ])
86
87 for (const server of servers) {
88 await checkDuration(server, 3)
89
90 const video = await server.videos.get({ id: videoUUID })
91 expect(new Date(video.publishedAt)).to.be.below(beforeTasks)
92 }
93 })
94
95 it('Should cut the end of the video', async function () {
96 this.timeout(120_000)
97 await renewVideo()
98
99 await createTasks([
100 {
101 name: 'cut',
102 options: {
103 end: 2
104 }
105 }
106 ])
107
108 for (const server of servers) {
109 await checkDuration(server, 2)
110 }
111 })
112
113 it('Should cut start/end of the video', async function () {
114 this.timeout(120_000)
115 await renewVideo('video_short1.webm') // 10 seconds video duration
116
117 await createTasks([
118 {
119 name: 'cut',
120 options: {
121 start: 2,
122 end: 6
123 }
124 }
125 ])
126
127 for (const server of servers) {
128 await checkDuration(server, 4)
129 }
130 })
131 })
132
133 describe('Intro/Outro', function () {
134
135 it('Should add an intro', async function () {
136 this.timeout(120_000)
137 await renewVideo()
138
139 await createTasks([
140 {
141 name: 'add-intro',
142 options: {
143 file: 'video_short.webm'
144 }
145 }
146 ])
147
148 for (const server of servers) {
149 await checkDuration(server, 10)
150 }
151 })
152
153 it('Should add an outro', async function () {
154 this.timeout(120_000)
155 await renewVideo()
156
157 await createTasks([
158 {
159 name: 'add-outro',
160 options: {
161 file: 'video_very_short_240p.mp4'
162 }
163 }
164 ])
165
166 for (const server of servers) {
167 await checkDuration(server, 7)
168 }
169 })
170
171 it('Should add an intro/outro', async function () {
172 this.timeout(120_000)
173 await renewVideo()
174
175 await createTasks([
176 {
177 name: 'add-intro',
178 options: {
179 file: 'video_very_short_240p.mp4'
180 }
181 },
182 {
183 name: 'add-outro',
184 options: {
185 // Different frame rate
186 file: 'video_short2.webm'
187 }
188 }
189 ])
190
191 for (const server of servers) {
192 await checkDuration(server, 12)
193 }
194 })
195
196 it('Should add an intro to a video without audio', async function () {
197 this.timeout(120_000)
198 await renewVideo('video_short_no_audio.mp4')
199
200 await createTasks([
201 {
202 name: 'add-intro',
203 options: {
204 file: 'video_very_short_240p.mp4'
205 }
206 }
207 ])
208
209 for (const server of servers) {
210 await checkDuration(server, 7)
211 }
212 })
213
214 it('Should add an outro without audio to a video with audio', async function () {
215 this.timeout(120_000)
216 await renewVideo()
217
218 await createTasks([
219 {
220 name: 'add-outro',
221 options: {
222 file: 'video_short_no_audio.mp4'
223 }
224 }
225 ])
226
227 for (const server of servers) {
228 await checkDuration(server, 10)
229 }
230 })
231
232 it('Should add an outro without audio to a video with audio', async function () {
233 this.timeout(120_000)
234 await renewVideo('video_short_no_audio.mp4')
235
236 await createTasks([
237 {
238 name: 'add-outro',
239 options: {
240 file: 'video_short_no_audio.mp4'
241 }
242 }
243 ])
244
245 for (const server of servers) {
246 await checkDuration(server, 10)
247 }
248 })
249 })
250
251 describe('Watermark', function () {
252
253 it('Should add a watermark to the video', async function () {
254 this.timeout(120_000)
255 await renewVideo()
256
257 const video = await servers[0].videos.get({ id: videoUUID })
258 const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
259
260 await createTasks([
261 {
262 name: 'add-watermark',
263 options: {
264 file: 'thumbnail.png'
265 }
266 }
267 ])
268
269 for (const server of servers) {
270 const video = await server.videos.get({ id: videoUUID })
271 const fileUrls = getAllFiles(video).map(f => f.fileUrl)
272
273 for (const oldUrl of oldFileUrls) {
274 expect(fileUrls).to.not.include(oldUrl)
275 }
276 }
277 })
278 })
279
280 describe('Complex tasks', function () {
281 it('Should run a complex task', async function () {
282 this.timeout(240_000)
283 await renewVideo()
284
285 await createTasks(VideoEditorCommand.getComplexTask())
286
287 for (const server of servers) {
288 await checkDuration(server, 9)
289 }
290 })
291 })
292
293 describe('HLS only video edition', function () {
294
295 before(async function () {
296 // Disable webtorrent
297 await servers[0].config.updateExistingSubConfig({
298 newConfig: {
299 transcoding: {
300 webtorrent: {
301 enabled: false
302 }
303 }
304 }
305 })
306 })
307
308 it('Should run a complex task on HLS only video', async function () {
309 this.timeout(240_000)
310 await renewVideo()
311
312 await createTasks(VideoEditorCommand.getComplexTask())
313
314 for (const server of servers) {
315 const video = await server.videos.get({ id: videoUUID })
316 expect(video.files).to.have.lengthOf(0)
317
318 await checkDuration(server, 9)
319 }
320 })
321 })
322
323 describe('Object storage video edition', function () {
324 if (areObjectStorageTestsDisabled()) return
325
326 before(async function () {
327 await ObjectStorageCommand.prepareDefaultBuckets()
328
329 await servers[0].kill()
330 await servers[0].run(ObjectStorageCommand.getDefaultConfig())
331
332 await servers[0].config.enableMinimumTranscoding()
333 })
334
335 it('Should run a complex task on a video in object storage', async function () {
336 this.timeout(240_000)
337 await renewVideo()
338
339 const video = await servers[0].videos.get({ id: videoUUID })
340 const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
341
342 await createTasks(VideoEditorCommand.getComplexTask())
343
344 for (const server of servers) {
345 const video = await server.videos.get({ id: videoUUID })
346 const files = getAllFiles(video)
347
348 for (const f of files) {
349 expect(oldFileUrls).to.not.include(f.fileUrl)
350 }
351
352 for (const webtorrentFile of video.files) {
353 expectStartWith(webtorrentFile.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl())
354 }
355
356 for (const hlsFile of video.streamingPlaylists[0].files) {
357 expectStartWith(hlsFile.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl())
358 }
359
360 await checkDuration(server, 9)
361 }
362 })
363 })
364
365 after(async function () {
366 await cleanupTests(servers)
367 })
368})
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 () {
45 before(async function () { 45 before(async function () {
46 this.timeout(120000) 46 this.timeout(120000)
47 47
48 servers = await createMultipleServers(2, { transcoding: { enabled: false } }) 48 servers = await createMultipleServers(2)
49 49
50 // Get the access tokens 50 // Get the access tokens
51 await setAccessTokensToServers(servers) 51 await setAccessTokensToServers(servers)
52 await setDefaultVideoChannel(servers) 52 await setDefaultVideoChannel(servers)
53 53
54 for (const server of servers) {
55 await server.config.disableTranscoding()
56 }
57
54 // Server 1 and server 2 follow each other 58 // Server 1 and server 2 follow each other
55 await doubleFollow(servers[0], servers[1]) 59 await doubleFollow(servers[0], servers[1])
56 60
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 () {
75 before(async function () { 75 before(async function () {
76 this.timeout(120000) 76 this.timeout(120000)
77 77
78 servers = await createMultipleServers(3, { transcoding: { enabled: false } }) 78 servers = await createMultipleServers(3)
79 79
80 // Get the access tokens 80 // Get the access tokens
81 await setAccessTokensToServers(servers) 81 await setAccessTokensToServers(servers)
82 await setDefaultVideoChannel(servers) 82 await setDefaultVideoChannel(servers)
83 await setDefaultAccountAvatar(servers) 83 await setDefaultAccountAvatar(servers)
84 84
85 for (const server of servers) {
86 await server.config.disableTranscoding()
87 }
88
85 // Server 1 and server 2 follow each other 89 // Server 1 and server 2 follow each other
86 await doubleFollow(servers[0], servers[1]) 90 await doubleFollow(servers[0], servers[1])
87 // Server 1 and server 3 follow each other 91 // 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 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import { canDoQuickTranscode } from '@server/helpers/ffprobe-utils' 6import { canDoQuickTranscode } from '@server/helpers/ffmpeg'
7import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared' 7import { generateHighBitrateVideo, generateVideoWithFramerate, getAllFiles } from '@server/tests/shared'
8import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils' 8import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils'
9import { getAudioStream, getMetadataFromFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils' 9import {
10 getAudioStream,
11 buildFileMetadata,
12 getVideoStreamBitrate,
13 getVideoStreamFPS,
14 getVideoStreamDimensionsInfo,
15 hasAudioStream
16} from '@shared/extra-utils'
10import { HttpStatusCode, VideoState } from '@shared/models' 17import { HttpStatusCode, VideoState } from '@shared/models'
11import { 18import {
12 cleanupTests, 19 cleanupTests,
@@ -287,8 +294,7 @@ describe('Test video transcoding', function () {
287 const file = videoDetails.files.find(f => f.resolution.id === 240) 294 const file = videoDetails.files.find(f => f.resolution.id === 240)
288 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 295 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
289 296
290 const probe = await getAudioStream(path) 297 expect(await hasAudioStream(path)).to.be.false
291 expect(probe).to.not.have.property('audioStream')
292 } 298 }
293 }) 299 })
294 300
@@ -478,14 +484,14 @@ describe('Test video transcoding', function () {
478 for (const resolution of [ 144, 240, 360, 480 ]) { 484 for (const resolution of [ 144, 240, 360, 480 ]) {
479 const file = videoDetails.files.find(f => f.resolution.id === resolution) 485 const file = videoDetails.files.find(f => f.resolution.id === resolution)
480 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 486 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
481 const fps = await getVideoFileFPS(path) 487 const fps = await getVideoStreamFPS(path)
482 488
483 expect(fps).to.be.below(31) 489 expect(fps).to.be.below(31)
484 } 490 }
485 491
486 const file = videoDetails.files.find(f => f.resolution.id === 720) 492 const file = videoDetails.files.find(f => f.resolution.id === 720)
487 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 493 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
488 const fps = await getVideoFileFPS(path) 494 const fps = await getVideoStreamFPS(path)
489 495
490 expect(fps).to.be.above(58).and.below(62) 496 expect(fps).to.be.above(58).and.below(62)
491 } 497 }
@@ -499,7 +505,7 @@ describe('Test video transcoding', function () {
499 { 505 {
500 tempFixturePath = await generateVideoWithFramerate(59) 506 tempFixturePath = await generateVideoWithFramerate(59)
501 507
502 const fps = await getVideoFileFPS(tempFixturePath) 508 const fps = await getVideoStreamFPS(tempFixturePath)
503 expect(fps).to.be.equal(59) 509 expect(fps).to.be.equal(59)
504 } 510 }
505 511
@@ -522,14 +528,14 @@ describe('Test video transcoding', function () {
522 { 528 {
523 const file = video.files.find(f => f.resolution.id === 240) 529 const file = video.files.find(f => f.resolution.id === 240)
524 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 530 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
525 const fps = await getVideoFileFPS(path) 531 const fps = await getVideoStreamFPS(path)
526 expect(fps).to.be.equal(25) 532 expect(fps).to.be.equal(25)
527 } 533 }
528 534
529 { 535 {
530 const file = video.files.find(f => f.resolution.id === 720) 536 const file = video.files.find(f => f.resolution.id === 720)
531 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 537 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
532 const fps = await getVideoFileFPS(path) 538 const fps = await getVideoStreamFPS(path)
533 expect(fps).to.be.equal(59) 539 expect(fps).to.be.equal(59)
534 } 540 }
535 } 541 }
@@ -563,9 +569,9 @@ describe('Test video transcoding', function () {
563 const file = video.files.find(f => f.resolution.id === resolution) 569 const file = video.files.find(f => f.resolution.id === resolution)
564 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 570 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
565 571
566 const bitrate = await getVideoFileBitrate(path) 572 const bitrate = await getVideoStreamBitrate(path)
567 const fps = await getVideoFileFPS(path) 573 const fps = await getVideoStreamFPS(path)
568 const dataResolution = await getVideoFileResolution(path) 574 const dataResolution = await getVideoStreamDimensionsInfo(path)
569 575
570 expect(resolution).to.equal(resolution) 576 expect(resolution).to.equal(resolution)
571 577
@@ -613,7 +619,7 @@ describe('Test video transcoding', function () {
613 const file = video.files.find(f => f.resolution.id === r) 619 const file = video.files.find(f => f.resolution.id === r)
614 620
615 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 621 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
616 const bitrate = await getVideoFileBitrate(path) 622 const bitrate = await getVideoStreamBitrate(path)
617 623
618 const inputBitrate = 60_000 624 const inputBitrate = 60_000
619 const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r }) 625 const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r })
@@ -637,7 +643,7 @@ describe('Test video transcoding', function () {
637 const video = await servers[1].videos.get({ id: videoUUID }) 643 const video = await servers[1].videos.get({ id: videoUUID })
638 const file = video.files.find(f => f.resolution.id === 240) 644 const file = video.files.find(f => f.resolution.id === 240)
639 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 645 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
640 const metadata = await getMetadataFromFile(path) 646 const metadata = await buildFileMetadata(path)
641 647
642 // expected format properties 648 // expected format properties
643 for (const p of [ 649 for (const p of [
@@ -668,8 +674,7 @@ describe('Test video transcoding', function () {
668 for (const server of servers) { 674 for (const server of servers) {
669 const videoDetails = await server.videos.get({ id: videoUUID }) 675 const videoDetails = await server.videos.get({ id: videoUUID })
670 676
671 const videoFiles = videoDetails.files 677 const videoFiles = getAllFiles(videoDetails)
672 .concat(videoDetails.streamingPlaylists[0].files)
673 expect(videoFiles).to.have.lengthOf(10) 678 expect(videoFiles).to.have.lengthOf(10)
674 679
675 for (const file of videoFiles) { 680 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 {
12 setAccessTokensToServers, 12 setAccessTokensToServers,
13 waitJobs 13 waitJobs
14} from '@shared/server-commands' 14} from '@shared/server-commands'
15import { getAllFiles } from '../shared'
15 16
16describe('Test update host scripts', function () { 17describe('Test update host scripts', function () {
17 let server: PeerTubeServer 18 let server: PeerTubeServer
@@ -108,7 +109,7 @@ describe('Test update host scripts', function () {
108 109
109 for (const video of data) { 110 for (const video of data) {
110 const videoDetails = await server.videos.get({ id: video.id }) 111 const videoDetails = await server.videos.get({ id: video.id })
111 const files = videoDetails.files.concat(videoDetails.streamingPlaylists[0].files) 112 const files = getAllFiles(videoDetails)
112 113
113 expect(files).to.have.lengthOf(8) 114 expect(files).to.have.lengthOf(8)
114 115
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 () {
410 before(async function () { 410 before(async function () {
411 this.timeout(60000) 411 this.timeout(60000)
412 412
413 await servers[0].config.updateCustomSubConfig({ 413 await servers[0].config.disableTranscoding()
414 newConfig: {
415 transcoding: {
416 enabled: false
417 }
418 }
419 })
420 414
421 for (const name of [ 'bad embed', 'good embed' ]) { 415 for (const name of [ 'bad embed', 'good embed' ]) {
422 { 416 {
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 @@
2 2
3import 'mocha' 3import 'mocha'
4import { expect } from 'chai' 4import { expect } from 'chai'
5import { getAudioStream, getVideoFileFPS, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' 5import { getAudioStream, getVideoStreamFPS, getVideoStream } from '@server/helpers/ffmpeg'
6import { VideoPrivacy } from '@shared/models'
6import { 7import {
7 cleanupTests, 8 cleanupTests,
8 createSingleServer, 9 createSingleServer,
@@ -13,7 +14,6 @@ import {
13 testFfmpegStreamError, 14 testFfmpegStreamError,
14 waitJobs 15 waitJobs
15} from '@shared/server-commands' 16} from '@shared/server-commands'
16import { VideoPrivacy } from '@shared/models'
17 17
18async function createLiveWrapper (server: PeerTubeServer) { 18async function createLiveWrapper (server: PeerTubeServer) {
19 const liveAttributes = { 19 const liveAttributes = {
@@ -92,7 +92,7 @@ describe('Test transcoding plugins', function () {
92 92
93 async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) { 93 async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) {
94 const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8` 94 const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8`
95 const videoFPS = await getVideoFileFPS(playlistUrl) 95 const videoFPS = await getVideoStreamFPS(playlistUrl)
96 96
97 if (type === 'above') { 97 if (type === 'above') {
98 expect(videoFPS).to.be.above(fps) 98 expect(videoFPS).to.be.above(fps)
@@ -252,7 +252,7 @@ describe('Test transcoding plugins', function () {
252 const audioProbe = await getAudioStream(path) 252 const audioProbe = await getAudioStream(path)
253 expect(audioProbe.audioStream.codec_name).to.equal('opus') 253 expect(audioProbe.audioStream.codec_name).to.equal('opus')
254 254
255 const videoProbe = await getVideoStreamFromFile(path) 255 const videoProbe = await getVideoStream(path)
256 expect(videoProbe.codec_name).to.equal('vp9') 256 expect(videoProbe.codec_name).to.equal('vp9')
257 }) 257 })
258 258
@@ -269,7 +269,7 @@ describe('Test transcoding plugins', function () {
269 const audioProbe = await getAudioStream(playlistUrl) 269 const audioProbe = await getAudioStream(playlistUrl)
270 expect(audioProbe.audioStream.codec_name).to.equal('opus') 270 expect(audioProbe.audioStream.codec_name).to.equal('opus')
271 271
272 const videoProbe = await getVideoStreamFromFile(playlistUrl) 272 const videoProbe = await getVideoStream(playlistUrl)
273 expect(videoProbe.codec_name).to.equal('h264') 273 expect(videoProbe.codec_name).to.equal('h264')
274 }) 274 })
275 }) 275 })
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'
3import { ensureDir, pathExists } from 'fs-extra' 3import { ensureDir, pathExists } from 'fs-extra'
4import { dirname } from 'path' 4import { dirname } from 'path'
5import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils' 5import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils'
6import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils' 6import { getVideoStreamBitrate, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '@shared/extra-utils'
7 7
8async function ensureHasTooBigBitrate (fixturePath: string) { 8async function ensureHasTooBigBitrate (fixturePath: string) {
9 const bitrate = await getVideoFileBitrate(fixturePath) 9 const bitrate = await getVideoStreamBitrate(fixturePath)
10 const dataResolution = await getVideoFileResolution(fixturePath) 10 const dataResolution = await getVideoStreamDimensionsInfo(fixturePath)
11 const fps = await getVideoFileFPS(fixturePath) 11 const fps = await getVideoStreamFPS(fixturePath)
12 12
13 const maxBitrate = getMaxBitrate({ ...dataResolution, fps }) 13 const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
14 expect(bitrate).to.be.above(maxBitrate) 14 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 (
240 return res 240 return res
241} 241}
242 242
243function getAllFiles (video: VideoDetails) {
244 const files = video.files
245
246 if (video.streamingPlaylists[0]) {
247 return files.concat(video.streamingPlaylists[0].files)
248 }
249
250 return files
251}
252
243// --------------------------------------------------------------------------- 253// ---------------------------------------------------------------------------
244 254
245export { 255export {
@@ -247,5 +257,6 @@ export {
247 checkUploadVideoParam, 257 checkUploadVideoParam,
248 uploadRandomVideoOnServers, 258 uploadRandomVideoOnServers,
249 checkVideoFilesWereRemoved, 259 checkVideoFilesWereRemoved,
250 saveVideoInServers 260 saveVideoInServers,
261 getAllFiles
251} 262}
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 {
40 MVideoRedundancyVideo, 40 MVideoRedundancyVideo,
41 MVideoShareActor, 41 MVideoShareActor,
42 MVideoThumbnail 42 MVideoThumbnail
43} from '../../types/models' 43} from './models'
44import { Writable } from 'stream' 44import { Writable } from 'stream'
45 45
46declare module 'express' { 46declare module 'express' {
@@ -60,6 +60,7 @@ declare module 'express' {
60 export type UploadFileForCheck = { 60 export type UploadFileForCheck = {
61 originalname: string 61 originalname: string
62 mimetype: string 62 mimetype: string
63 size: number
63 } 64 }
64 65
65 export type UploadFilesForCheck = { 66 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) {
17 }) 17 })
18} 18}
19 19
20// ---------------------------------------------------------------------------
21// Audio
22// ---------------------------------------------------------------------------
23
20async function isAudioFile (path: string, existingProbe?: FfprobeData) { 24async function isAudioFile (path: string, existingProbe?: FfprobeData) {
21 const videoStream = await getVideoStreamFromFile(path, existingProbe) 25 const videoStream = await getVideoStream(path, existingProbe)
22 26
23 return !videoStream 27 return !videoStream
24} 28}
25 29
30async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
31 const { audioStream } = await getAudioStream(path, existingProbe)
32
33 return !!audioStream
34}
35
26async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) { 36async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
27 // without position, ffprobe considers the last input only 37 // without position, ffprobe considers the last input only
28 // we make it consider the first input only 38 // we make it consider the first input only
@@ -78,29 +88,26 @@ function getMaxAudioBitrate (type: 'aac' | 'mp3' | string, bitrate: number) {
78 } 88 }
79} 89}
80 90
81async function getVideoStreamSize (path: string, existingProbe?: FfprobeData): Promise<{ width: number, height: number }> { 91// ---------------------------------------------------------------------------
82 const videoStream = await getVideoStreamFromFile(path, existingProbe) 92// Video
83 93// ---------------------------------------------------------------------------
84 return videoStream === null
85 ? { width: 0, height: 0 }
86 : { width: videoStream.width, height: videoStream.height }
87}
88 94
89async function getVideoFileResolution (path: string, existingProbe?: FfprobeData) { 95async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
90 const size = await getVideoStreamSize(path, existingProbe) 96 const videoStream = await getVideoStream(path, existingProbe)
97 if (!videoStream) return undefined
91 98
92 return { 99 return {
93 width: size.width, 100 width: videoStream.width,
94 height: size.height, 101 height: videoStream.height,
95 ratio: Math.max(size.height, size.width) / Math.min(size.height, size.width), 102 ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width),
96 resolution: Math.min(size.height, size.width), 103 resolution: Math.min(videoStream.height, videoStream.width),
97 isPortraitMode: size.height > size.width 104 isPortraitMode: videoStream.height > videoStream.width
98 } 105 }
99} 106}
100 107
101async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) { 108async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
102 const videoStream = await getVideoStreamFromFile(path, existingProbe) 109 const videoStream = await getVideoStream(path, existingProbe)
103 if (videoStream === null) return 0 110 if (!videoStream) return 0
104 111
105 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) { 112 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
106 const valuesText: string = videoStream[key] 113 const valuesText: string = videoStream[key]
@@ -116,19 +123,19 @@ async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) {
116 return 0 123 return 0
117} 124}
118 125
119async function getMetadataFromFile (path: string, existingProbe?: FfprobeData) { 126async function buildFileMetadata (path: string, existingProbe?: FfprobeData) {
120 const metadata = existingProbe || await ffprobePromise(path) 127 const metadata = existingProbe || await ffprobePromise(path)
121 128
122 return new VideoFileMetadata(metadata) 129 return new VideoFileMetadata(metadata)
123} 130}
124 131
125async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData): Promise<number> { 132async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
126 const metadata = await getMetadataFromFile(path, existingProbe) 133 const metadata = await buildFileMetadata(path, existingProbe)
127 134
128 let bitrate = metadata.format.bit_rate as number 135 let bitrate = metadata.format.bit_rate as number
129 if (bitrate && !isNaN(bitrate)) return bitrate 136 if (bitrate && !isNaN(bitrate)) return bitrate
130 137
131 const videoStream = await getVideoStreamFromFile(path, existingProbe) 138 const videoStream = await getVideoStream(path, existingProbe)
132 if (!videoStream) return undefined 139 if (!videoStream) return undefined
133 140
134 bitrate = videoStream?.bit_rate 141 bitrate = videoStream?.bit_rate
@@ -137,51 +144,30 @@ async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData):
137 return undefined 144 return undefined
138} 145}
139 146
140async function getDurationFromVideoFile (path: string, existingProbe?: FfprobeData) { 147async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
141 const metadata = await getMetadataFromFile(path, existingProbe) 148 const metadata = await buildFileMetadata(path, existingProbe)
142 149
143 return Math.round(metadata.format.duration) 150 return Math.round(metadata.format.duration)
144} 151}
145 152
146async function getVideoStreamFromFile (path: string, existingProbe?: FfprobeData) { 153async function getVideoStream (path: string, existingProbe?: FfprobeData) {
147 const metadata = await getMetadataFromFile(path, existingProbe) 154 const metadata = await buildFileMetadata(path, existingProbe)
148
149 return metadata.streams.find(s => s.codec_type === 'video') || null
150}
151
152async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
153 const parsedAudio = await getAudioStream(path, probe)
154
155 if (!parsedAudio.audioStream) return true
156
157 if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
158
159 const audioBitrate = parsedAudio.bitrate
160 if (!audioBitrate) return false
161
162 const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
163 if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
164
165 const channelLayout = parsedAudio.audioStream['channel_layout']
166 // Causes playback issues with Chrome
167 if (!channelLayout || channelLayout === 'unknown') return false
168 155
169 return true 156 return metadata.streams.find(s => s.codec_type === 'video')
170} 157}
171 158
172// --------------------------------------------------------------------------- 159// ---------------------------------------------------------------------------
173 160
174export { 161export {
175 getVideoStreamSize, 162 getVideoStreamDimensionsInfo,
176 getVideoFileResolution, 163 buildFileMetadata,
177 getMetadataFromFile,
178 getMaxAudioBitrate, 164 getMaxAudioBitrate,
179 getVideoStreamFromFile, 165 getVideoStream,
180 getDurationFromVideoFile, 166 getVideoStreamDuration,
181 getAudioStream, 167 getAudioStream,
182 getVideoFileFPS, 168 getVideoStreamFPS,
183 isAudioFile, 169 isAudioFile,
184 ffprobePromise, 170 ffprobePromise,
185 getVideoFileBitrate, 171 getVideoStreamBitrate,
186 canDoQuickAudioTranscode 172 hasAudioStream
187} 173}
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 {
143 } 143 }
144 } 144 }
145 145
146 videoEditor: {
147 enabled: boolean
148 }
149
146 import: { 150 import: {
147 videos: { 151 videos: {
148 concurrency: number 152 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 @@
1import { ContextType } from '../activitypub/context' 1import { ContextType } from '../activitypub/context'
2import { VideoEditorTaskCut } from '../videos/editor'
2import { VideoResolution } from '../videos/file/video-resolution.enum' 3import { VideoResolution } from '../videos/file/video-resolution.enum'
3import { SendEmailOptions } from './emailer.model' 4import { SendEmailOptions } from './emailer.model'
4 5
@@ -20,6 +21,7 @@ export type JobType =
20 | 'video-live-ending' 21 | 'video-live-ending'
21 | 'actor-keys' 22 | 'actor-keys'
22 | 'move-to-object-storage' 23 | 'move-to-object-storage'
24 | 'video-edition'
23 25
24export interface Job { 26export interface Job {
25 id: number 27 id: number
@@ -155,3 +157,40 @@ export interface MoveObjectStoragePayload {
155 videoUUID: string 157 videoUUID: string
156 isNewVideo: boolean 158 isNewVideo: boolean
157} 159}
160
161export type VideoEditorTaskCutPayload = VideoEditorTaskCut
162
163export type VideoEditorTaskIntroPayload = {
164 name: 'add-intro'
165
166 options: {
167 file: string
168 }
169}
170
171export type VideoEditorTaskOutroPayload = {
172 name: 'add-outro'
173
174 options: {
175 file: string
176 }
177}
178
179export type VideoEditorTaskWatermarkPayload = {
180 name: 'add-watermark'
181
182 options: {
183 file: string
184 }
185}
186
187export type VideoEditionTaskPayload =
188 VideoEditorTaskCutPayload |
189 VideoEditorTaskIntroPayload |
190 VideoEditorTaskOutroPayload |
191 VideoEditorTaskWatermarkPayload
192
193export interface VideoEditionPayload {
194 videoUUID: string
195 tasks: VideoEditionTaskPayload[]
196}
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 {
167 } 167 }
168 } 168 }
169 169
170 videoEditor: {
171 enabled: boolean
172 }
173
170 import: { 174 import: {
171 videos: { 175 videos: {
172 http: { 176 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 @@
1export interface VideoEditorCreateEdition {
2 tasks: VideoEditorTask[]
3}
4
5export type VideoEditorTask =
6 VideoEditorTaskCut |
7 VideoEditorTaskIntro |
8 VideoEditorTaskOutro |
9 VideoEditorTaskWatermark
10
11export interface VideoEditorTaskCut {
12 name: 'cut'
13
14 options: {
15 start?: number
16 end?: number
17 }
18}
19
20export interface VideoEditorTaskIntro {
21 name: 'add-intro'
22
23 options: {
24 file: Blob | string
25 }
26}
27
28export interface VideoEditorTaskOutro {
29 name: 'add-outro'
30
31 options: {
32 file: Blob | string
33 }
34}
35
36export interface VideoEditorTaskWatermark {
37 name: 'add-watermark'
38
39 options: {
40 file: Blob | string
41 }
42}
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'
3export * from './change-ownership' 3export * from './change-ownership'
4export * from './channel' 4export * from './channel'
5export * from './comment' 5export * from './comment'
6export * from './editor'
6export * from './live' 7export * from './live'
7export * from './file' 8export * from './file'
8export * from './import' 9export * 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 = {
2 MIN: number 2 MIN: number
3 STANDARD: number[] 3 STANDARD: number[]
4 HD_STANDARD: number[] 4 HD_STANDARD: number[]
5 AUDIO_MERGE: number
5 AVERAGE: number 6 AVERAGE: number
6 MAX: number 7 MAX: number
7 KEEP_ORIGIN_FPS_RESOLUTION_MIN: number 8 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 = {
7 7
8 resolution: VideoResolution 8 resolution: VideoResolution
9 9
10 // Could be null for "merge audio" transcoding 10 // If PeerTube applies a filter, transcoding profile must not copy input stream
11 fps?: number 11 canCopyAudio: boolean
12 canCopyVideo: boolean
13
14 fps: number
12 15
13 // Could be undefined if we could not get input bitrate (some RTMP streams for example) 16 // Could be undefined if we could not get input bitrate (some RTMP streams for example)
14 inputBitrate: number 17 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 {
6 LIVE_ENDED = 5, 6 LIVE_ENDED = 5,
7 TO_MOVE_TO_EXTERNAL_STORAGE = 6, 7 TO_MOVE_TO_EXTERNAL_STORAGE = 6,
8 TRANSCODING_FAILED = 7, 8 TRANSCODING_FAILED = 7,
9 TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8 9 TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8,
10 TO_EDIT = 9
10} 11}
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 {
59 newConfig: { 59 newConfig: {
60 transcoding: { 60 transcoding: {
61 enabled: false 61 enabled: false
62 },
63 videoEditor: {
64 enabled: false
62 } 65 }
63 } 66 }
64 }) 67 })
@@ -69,6 +72,10 @@ export class ConfigCommand extends AbstractCommand {
69 newConfig: { 72 newConfig: {
70 transcoding: { 73 transcoding: {
71 enabled: true, 74 enabled: true,
75
76 allowAudioFiles: true,
77 allowAdditionalExtensions: true,
78
72 resolutions: ConfigCommand.getCustomConfigResolutions(true), 79 resolutions: ConfigCommand.getCustomConfigResolutions(true),
73 80
74 webtorrent: { 81 webtorrent: {
@@ -82,6 +89,28 @@ export class ConfigCommand extends AbstractCommand {
82 }) 89 })
83 } 90 }
84 91
92 enableMinimumTranscoding (webtorrent = true, hls = true) {
93 return this.updateExistingSubConfig({
94 newConfig: {
95 transcoding: {
96 enabled: true,
97 resolutions: {
98 ...ConfigCommand.getCustomConfigResolutions(false),
99
100 '240p': true
101 },
102
103 webtorrent: {
104 enabled: webtorrent
105 },
106 hls: {
107 enabled: hls
108 }
109 }
110 }
111 })
112 }
113
85 getConfig (options: OverrideCommandOptions = {}) { 114 getConfig (options: OverrideCommandOptions = {}) {
86 const path = '/api/v1/config' 115 const path = '/api/v1/config'
87 116
@@ -148,7 +177,7 @@ export class ConfigCommand extends AbstractCommand {
148 async updateExistingSubConfig (options: OverrideCommandOptions & { 177 async updateExistingSubConfig (options: OverrideCommandOptions & {
149 newConfig: DeepPartial<CustomConfig> 178 newConfig: DeepPartial<CustomConfig>
150 }) { 179 }) {
151 const existing = await this.getCustomConfig(options) 180 const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 })
152 181
153 return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) }) 182 return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) })
154 } 183 }
@@ -282,6 +311,9 @@ export class ConfigCommand extends AbstractCommand {
282 } 311 }
283 } 312 }
284 }, 313 },
314 videoEditor: {
315 enabled: false
316 },
285 import: { 317 import: {
286 videos: { 318 videos: {
287 concurrency: 3, 319 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 {
25 PlaylistsCommand, 25 PlaylistsCommand,
26 ServicesCommand, 26 ServicesCommand,
27 StreamingPlaylistsCommand, 27 StreamingPlaylistsCommand,
28 VideoEditorCommand,
28 VideosCommand 29 VideosCommand
29} from '../videos' 30} from '../videos'
30import { CommentsCommand } from '../videos/comments-command' 31import { CommentsCommand } from '../videos/comments-command'
@@ -124,6 +125,7 @@ export class PeerTubeServer {
124 login?: LoginCommand 125 login?: LoginCommand
125 users?: UsersCommand 126 users?: UsersCommand
126 objectStorage?: ObjectStorageCommand 127 objectStorage?: ObjectStorageCommand
128 videoEditor?: VideoEditorCommand
127 videos?: VideosCommand 129 videos?: VideosCommand
128 130
129 constructor (options: { serverNumber: number } | { url: string }) { 131 constructor (options: { serverNumber: number } | { url: string }) {
@@ -394,5 +396,6 @@ export class PeerTubeServer {
394 this.users = new UsersCommand(this) 396 this.users = new UsersCommand(this)
395 this.videos = new VideosCommand(this) 397 this.videos = new VideosCommand(this)
396 this.objectStorage = new ObjectStorageCommand(this) 398 this.objectStorage = new ObjectStorageCommand(this)
399 this.videoEditor = new VideoEditorCommand(this)
397 } 400 }
398} 401}
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'
12export * from './services-command' 12export * from './services-command'
13export * from './streaming-playlists-command' 13export * from './streaming-playlists-command'
14export * from './comments-command' 14export * from './comments-command'
15export * from './video-editor-command'
15export * from './videos-command' 16export * 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 @@
1import { HttpStatusCode, VideoEditorTask } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class VideoEditorCommand extends AbstractCommand {
5
6 static getComplexTask (): VideoEditorTask[] {
7 return [
8 // Total duration: 2
9 {
10 name: 'cut',
11 options: {
12 start: 1,
13 end: 3
14 }
15 },
16
17 // Total duration: 7
18 {
19 name: 'add-outro',
20 options: {
21 file: 'video_short.webm'
22 }
23 },
24
25 {
26 name: 'add-watermark',
27 options: {
28 file: 'thumbnail.png'
29 }
30 },
31
32 // Total duration: 9
33 {
34 name: 'add-intro',
35 options: {
36 file: 'video_very_short_240p.mp4'
37 }
38 }
39 ]
40 }
41
42 createEditionTasks (options: OverrideCommandOptions & {
43 videoId: number | string
44 tasks: VideoEditorTask[]
45 }) {
46 const path = '/api/v1/videos/' + options.videoId + '/editor/edit'
47 const attaches: { [id: string]: any } = {}
48
49 for (let i = 0; i < options.tasks.length; i++) {
50 const task = options.tasks[i]
51
52 if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') {
53 attaches[`tasks[${i}][options][file]`] = task.options.file
54 }
55 }
56
57 return this.postUploadRequest({
58 ...options,
59
60 path,
61 attaches,
62 fields: { tasks: options.tasks },
63 implicitToken: true,
64 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
65 })
66 }
67}