diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index f2eaa3033..e3b6f8305 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
resolutions: {}
}
},
+ videoEditor: {
+ enabled: null
+ },
autoBlacklist: {
videos: {
ofUsers: {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
index 1158f027b..2be855756 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
@@ -192,4 +192,29 @@
+
+
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
index 3397c3dbd..948c10b69 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
@@ -71,6 +71,8 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
}
private checkTranscodingFields () {
+ const transcodingControl = this.form.get('transcoding.enabled')
+ const videoEditorControl = this.form.get('videoEditor.enabled')
const hlsControl = this.form.get('transcoding.hls.enabled')
const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
@@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
webtorrentControl.enable()
}
})
+
+ transcodingControl.valueChanges
+ .subscribe(newValue => {
+ if (newValue === false) {
+ videoEditorControl.setValue(false)
+ }
+ })
}
}
diff --git a/client/src/app/+admin/overview/videos/video-list.component.scss b/client/src/app/+admin/overview/videos/video-list.component.scss
index 543cb433c..616b9bc6b 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.scss
+++ b/client/src/app/+admin/overview/videos/video-list.component.scss
@@ -1,5 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
+
my-embed {
display: block;
max-width: 500px;
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
index 261e87f99..c998b7c49 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.ts
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
-import { VideoChannel, VideoSortField } from '@shared/models'
+import { VideoChannel, VideoSortField, VideoState } from '@shared/models'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
@Component({
@@ -204,6 +204,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
private buildActions () {
this.videoActions = [
+ {
+ label: $localize`Editor`,
+ linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
+ isDisplayed: ({ video }) => video.state.id === VideoState.PUBLISHED,
+ iconName: 'film'
+ },
{
label: $localize`Display live information`,
handler: ({ video }) => this.displayLiveInformation(video),
diff --git a/client/src/app/+video-editor/edit/index.ts b/client/src/app/+video-editor/edit/index.ts
new file mode 100644
index 000000000..390ca80fc
--- /dev/null
+++ b/client/src/app/+video-editor/edit/index.ts
@@ -0,0 +1,2 @@
+export * from './video-editor-edit.component'
+export * from './video-editor-edit.resolver'
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.html b/client/src/app/+video-editor/edit/video-editor-edit.component.html
new file mode 100644
index 000000000..d33dfaf18
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.html
@@ -0,0 +1,88 @@
+
+
Edit {{ video.name }}
+
+
+
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.scss b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
new file mode 100644
index 000000000..43f336f59
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.scss
@@ -0,0 +1,76 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.columns {
+ display: flex;
+
+ .information {
+ width: 100%;
+ margin-left: 50px;
+
+ > div {
+ margin-bottom: 30px;
+ }
+
+ @media screen and (max-width: $small-view) {
+ display: none;
+ }
+ }
+}
+
+h1 {
+ font-size: 20px;
+}
+
+h2 {
+ font-weight: $font-bold;
+ font-size: 16px;
+ color: pvar(--mainColor);
+ background-color: pvar(--mainBackgroundColor);
+ padding: 0 5px;
+ width: fit-content;
+ margin: -8px 0 0;
+}
+
+.section {
+ $min-width: 600px;
+
+ @include padding-left(10px);
+
+ min-width: $min-width;
+
+ margin-bottom: 50px;
+ border: 1px solid $separator-border-color;
+ border-radius: 5px;
+ width: fit-content;
+
+ .form-group,
+ .description {
+ @include margin-left(5px);
+ }
+
+ .description {
+ color: pvar(--greyForegroundColor);
+ margin-top: 5px;
+ margin-bottom: 15px;
+ }
+
+ @media screen and (max-width: $min-width) {
+ min-width: none;
+ }
+}
+
+my-timestamp-input {
+ display: block;
+}
+
+my-embed {
+ display: block;
+ max-width: 500px;
+ width: 100%;
+}
+
+my-reactive-file {
+ display: block;
+ width: fit-content;
+}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.ts b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
new file mode 100644
index 000000000..93d7ffcec
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.component.ts
@@ -0,0 +1,202 @@
+import { Component, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { ConfirmService, Notifier, ServerService } from '@app/core'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { Video, VideoDetails } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { secondsToTime } from '@shared/core-utils'
+import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
+import { VideoEditorService } from '../shared'
+
+@Component({
+ selector: 'my-video-editor-edit',
+ templateUrl: './video-editor-edit.component.html',
+ styleUrls: [ './video-editor-edit.component.scss' ]
+})
+export class VideoEditorEditComponent extends FormReactive implements OnInit {
+ isRunningEdition = false
+
+ video: VideoDetails
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private serverService: ServerService,
+ private notifier: Notifier,
+ private router: Router,
+ private route: ActivatedRoute,
+ private videoEditorService: VideoEditorService,
+ private loadingBar: LoadingBarService,
+ private confirmService: ConfirmService
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.video = this.route.snapshot.data.video
+
+ const defaultValues = {
+ cut: {
+ start: 0,
+ end: this.video.duration
+ }
+ }
+
+ this.buildForm({
+ cut: {
+ start: null,
+ end: null
+ },
+ 'add-intro': {
+ file: null
+ },
+ 'add-outro': {
+ file: null
+ },
+ 'add-watermark': {
+ file: null
+ }
+ }, defaultValues)
+ }
+
+ get videoExtensions () {
+ return this.serverService.getHTMLConfig().video.file.extensions
+ }
+
+ get imageExtensions () {
+ return this.serverService.getHTMLConfig().video.image.extensions
+ }
+
+ async runEdition () {
+ if (this.isRunningEdition) return
+
+ const title = $localize`Are you sure you want to edit "${this.video.name}"?`
+ const listHTML = this.getTasksSummary().map(t => `${t}`).join('')
+
+ // eslint-disable-next-line max-len
+ const confirmHTML = $localize`The current video will be overwritten by this edited video and you won't be able to recover it.
` +
+ $localize`As a reminder, the following tasks will be executed: ${listHTML}
`
+
+ if (await this.confirmService.confirm(confirmHTML, title) !== true) return
+
+ this.isRunningEdition = true
+
+ const tasks = this.buildTasks()
+
+ this.loadingBar.useRef().start()
+
+ return this.videoEditorService.editVideo(this.video.uuid, tasks)
+ .subscribe({
+ next: () => {
+ this.notifier.success($localize`Video updated.`)
+ this.router.navigateByUrl(Video.buildWatchUrl(this.video))
+ },
+
+ error: err => {
+ this.loadingBar.useRef().complete()
+ this.isRunningEdition = false
+ this.notifier.error(err.message)
+ console.error(err)
+ }
+ })
+ }
+
+ getIntroOutroTooltip () {
+ return $localize`(extensions: ${this.videoExtensions.join(', ')})`
+ }
+
+ getWatermarkTooltip () {
+ return $localize`(extensions: ${this.imageExtensions.join(', ')})`
+ }
+
+ noEdition () {
+ return this.buildTasks().length === 0
+ }
+
+ getTasksSummary () {
+ const tasks = this.buildTasks()
+
+ return tasks.map(t => {
+ if (t.name === 'add-intro') {
+ return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
+ }
+
+ if (t.name === 'add-outro') {
+ return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
+ }
+
+ if (t.name === 'add-watermark') {
+ return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
+ }
+
+ if (t.name === 'cut') {
+ const { start, end } = t.options
+
+ if (start !== undefined && end !== undefined) {
+ return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
+ }
+
+ if (start !== undefined) {
+ return $localize`Video will begin at ${secondsToTime(start)}`
+ }
+
+ if (end !== undefined) {
+ return $localize`Video will stop at ${secondsToTime(end)}`
+ }
+ }
+
+ return ''
+ })
+ }
+
+ private getFilename (obj: any) {
+ return obj.name
+ }
+
+ private buildTasks () {
+ const tasks: VideoEditorTask[] = []
+ const value = this.form.value
+
+ const cut = value['cut']
+ if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
+
+ const options: VideoEditorTaskCut['options'] = {}
+ if (cut['start'] !== 0) options.start = cut['start']
+ if (cut['end'] !== this.video.duration) options.end = cut['end']
+
+ tasks.push({
+ name: 'cut',
+ options
+ })
+ }
+
+ if (value['add-intro']?.['file']) {
+ tasks.push({
+ name: 'add-intro',
+ options: {
+ file: value['add-intro']['file']
+ }
+ })
+ }
+
+ if (value['add-outro']?.['file']) {
+ tasks.push({
+ name: 'add-outro',
+ options: {
+ file: value['add-outro']['file']
+ }
+ })
+ }
+
+ if (value['add-watermark']?.['file']) {
+ tasks.push({
+ name: 'add-watermark',
+ options: {
+ file: value['add-watermark']['file']
+ }
+ })
+ }
+
+ return tasks
+ }
+
+}
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
new file mode 100644
index 000000000..7b95ae834
--- /dev/null
+++ b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts
@@ -0,0 +1,18 @@
+
+import { Injectable } from '@angular/core'
+import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
+import { VideoService } from '@app/shared/shared-main'
+
+@Injectable()
+export class VideoEditorEditResolver implements Resolve {
+ constructor (
+ private videoService: VideoService
+ ) {
+ }
+
+ resolve (route: ActivatedRouteSnapshot) {
+ const videoId: string = route.params['videoId']
+
+ return this.videoService.getVideo({ videoId })
+ }
+}
diff --git a/client/src/app/+video-editor/index.ts b/client/src/app/+video-editor/index.ts
new file mode 100644
index 000000000..5a9e9fdd0
--- /dev/null
+++ b/client/src/app/+video-editor/index.ts
@@ -0,0 +1 @@
+export * from './video-editor.module'
diff --git a/client/src/app/+video-editor/shared/index.ts b/client/src/app/+video-editor/shared/index.ts
new file mode 100644
index 000000000..eaf88b6f4
--- /dev/null
+++ b/client/src/app/+video-editor/shared/index.ts
@@ -0,0 +1 @@
+export * from './video-editor.service'
diff --git a/client/src/app/+video-editor/shared/video-editor.service.ts b/client/src/app/+video-editor/shared/video-editor.service.ts
new file mode 100644
index 000000000..5b7053039
--- /dev/null
+++ b/client/src/app/+video-editor/shared/video-editor.service.ts
@@ -0,0 +1,28 @@
+import { catchError } from 'rxjs'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { objectToFormData } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models'
+
+@Injectable()
+export class VideoEditorService {
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor
+ ) {}
+
+ editVideo (videoId: number | string, tasks: VideoEditorTask[]) {
+ const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit'
+ const body: VideoEditorCreateEdition = {
+ tasks
+ }
+
+ const data = objectToFormData(body)
+
+ return this.authHttp.post(url, data)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+}
diff --git a/client/src/app/+video-editor/video-editor-routing.module.ts b/client/src/app/+video-editor/video-editor-routing.module.ts
new file mode 100644
index 000000000..9f37a0dae
--- /dev/null
+++ b/client/src/app/+video-editor/video-editor-routing.module.ts
@@ -0,0 +1,30 @@
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { VideoEditorEditResolver } from './edit'
+import { VideoEditorEditComponent } from './edit/video-editor-edit.component'
+
+const videoEditorRoutes: Routes = [
+ {
+ path: '',
+ children: [
+ {
+ path: 'edit/:videoId',
+ component: VideoEditorEditComponent,
+ data: {
+ meta: {
+ title: $localize`Edit video`
+ }
+ },
+ resolve: {
+ video: VideoEditorEditResolver
+ }
+ }
+ ]
+ }
+]
+
+@NgModule({
+ imports: [ RouterModule.forChild(videoEditorRoutes) ],
+ exports: [ RouterModule ]
+})
+export class VideoEditorRoutingModule {}
diff --git a/client/src/app/+video-editor/video-editor.module.ts b/client/src/app/+video-editor/video-editor.module.ts
new file mode 100644
index 000000000..7bbebc17b
--- /dev/null
+++ b/client/src/app/+video-editor/video-editor.module.ts
@@ -0,0 +1,27 @@
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit'
+import { VideoEditorService } from './shared'
+import { VideoEditorRoutingModule } from './video-editor-routing.module'
+
+@NgModule({
+ imports: [
+ VideoEditorRoutingModule,
+
+ SharedMainModule,
+ SharedFormModule
+ ],
+
+ declarations: [
+ VideoEditorEditComponent
+ ],
+
+ exports: [],
+
+ providers: [
+ VideoEditorService,
+ VideoEditorEditResolver
+ ]
+})
+export class VideoEditorModule { }
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index e59238ffe..6e8a64f46 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
playlist: false,
download: true,
update: true,
+ editor: true,
blacklist: true,
delete: true,
report: true,
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
index 0c4d46714..c6ffb1abd 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
@@ -14,6 +14,10 @@
The video is being transcoded, it may not work properly.
+
+ The video is being edited, it may not work properly.
+
+
The video is being moved to an external server, it may not work properly.
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
index a3d3fa6fb..79b56705f 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
@@ -14,6 +14,10 @@ export class VideoAlertComponent {
return this.video && this.video.state.id === VideoState.TO_TRANSCODE
}
+ isVideoToEdit () {
+ return this.video && this.video.state.id === VideoState.TO_EDIT
+ }
+
isVideoTranscodingFailed () {
return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
}
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts
index b5afc9c92..cd499845b 100644
--- a/client/src/app/app-routing.module.ts
+++ b/client/src/app/app-routing.module.ts
@@ -143,6 +143,12 @@ const routes: Routes = [
canActivateChild: [ MetaGuard ]
},
+ {
+ path: 'video-editor',
+ loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
+ canActivateChild: [ MetaGuard ]
+ },
+
// Matches /@:actorName
{
matcher: (url): UrlMatchResult => {
diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts
index 07a12c6f6..6b3a6c773 100644
--- a/client/src/app/shared/shared-forms/form-reactive.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.ts
@@ -24,7 +24,7 @@ export abstract class FormReactive {
this.formErrors = formErrors
this.validationMessages = validationMessages
- this.form.statusChanges.subscribe(async status => {
+ this.form.statusChanges.subscribe(async () => {
// FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
await this.waitPendingCheck()
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index 0fe50ac9b..f67d5bb33 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -30,7 +30,7 @@ export class FormValidatorService {
if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
- const defaultValue = defaultValues[name] || ''
+ const defaultValue = defaultValues[name] ?? ''
if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
else group[name] = [ defaultValue ]
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html
index c57a4b32c..c89a7b019 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.html
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.html
@@ -1,4 +1,5 @@
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss
index d2358c027..27d6fa173 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.scss
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss
@@ -1,10 +1,10 @@
@use '_variables' as *;
+@use '_mixins' as *;
p-inputmask {
::ng-deep input {
width: 80px;
font-size: 15px;
- border: 0;
&:focus-within,
&:focus {
@@ -16,4 +16,16 @@ p-inputmask {
opacity: 0.5;
}
}
+
+ &.border-disabled {
+ ::ng-deep input {
+ border: 0;
+ }
+ }
+
+ &:not(.border-disabled) {
+ ::ng-deep input {
+ @include peertube-input-text(80px);
+ }
+ }
}
diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts
index 3fc705905..79ca63673 100644
--- a/client/src/app/shared/shared-forms/timestamp-input.component.ts
+++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts
@@ -18,6 +18,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
@Input() maxTimestamp: number
@Input() timestamp: number
@Input() disabled = false
+ @Input() inputName: string
+ @Input() disableBorder = true
@Output() inputBlur = new EventEmitter()
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index c2a318285..abbfc63f8 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -1,8 +1,8 @@
import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
-import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core'
+import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-import { VideoCaption } from '@shared/models'
+import { VideoCaption, VideoState } from '@shared/models'
import {
Actor,
DropdownAction,
@@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
liveInfo?: boolean
removeFiles?: boolean
transcoding?: boolean
+ editor?: boolean
}
@Component({
@@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
mute: true,
liveInfo: false,
removeFiles: false,
- transcoding: false
+ transcoding: false,
+ editor: true
}
@Input() placement = 'left'
@@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
private videoBlocklistService: VideoBlockService,
private screenService: ScreenService,
private videoService: VideoService,
- private redundancyService: RedundancyService
+ private redundancyService: RedundancyService,
+ private serverService: ServerService
) { }
get user () {
@@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
return this.video.isUpdatableBy(this.user)
}
+ isVideoEditable () {
+ return this.serverService.getHTMLConfig().videoEditor.enabled &&
+ this.video.state?.id === VideoState.PUBLISHED &&
+ this.video.isUpdatableBy(this.user)
+ }
+
isVideoRemovable () {
return this.video.isRemovableBy(this.user)
}
@@ -329,6 +338,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
iconName: 'edit',
isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
},
+ {
+ label: $localize`Editor`,
+ linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
+ iconName: 'film',
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.editor && this.isVideoEditable()
+ },
{
label: $localize`Block`,
handler: () => this.showBlockModal(),
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 847e401ed..7de9fc8e2 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -195,6 +195,10 @@ export class VideoMiniatureComponent implements OnInit {
return $localize`To import`
}
+ if (video.state.id === VideoState.TO_EDIT) {
+ return $localize`To edit`
+ }
+
return ''
}
diff --git a/config/default.yaml b/config/default.yaml
index 23be08f85..1e7fb9e5b 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -425,6 +425,10 @@ live:
1440p: false
2160p: false
+video_editor:
+ # Enable video edition by users (cut, add intro/outro, add watermark etc)
+ enabled: false
+
import:
# Add ability for your users to import remote videos (from YouTube, torrent...)
videos:
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 675801caa..d1f18ecde 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -433,6 +433,10 @@ live:
1440p: false
2160p: false
+video_editor:
+ # Enable video edition by users (cut, add intro/outro, add watermark etc)
+ enabled: false
+
import:
# Add ability for your users to import remote videos (from YouTube, torrent...)
videos:
diff --git a/config/test-1.yaml b/config/test-1.yaml
index d5f8299e0..0f6d56f1a 100644
--- a/config/test-1.yaml
+++ b/config/test-1.yaml
@@ -37,6 +37,9 @@ signup:
transcoding:
enabled: false
+video_editor:
+ enabled: false
+
live:
rtmp:
port: 1936
diff --git a/config/test-3.yaml b/config/test-3.yaml
index 594439b62..3cd3ddba7 100644
--- a/config/test-3.yaml
+++ b/config/test-3.yaml
@@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
diff --git a/config/test-4.yaml b/config/test-4.yaml
index 1e6368bf7..6d8e51945 100644
--- a/config/test-4.yaml
+++ b/config/test-4.yaml
@@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
diff --git a/config/test-5.yaml b/config/test-5.yaml
index 97f18a7a0..5f2157fec 100644
--- a/config/test-5.yaml
+++ b/config/test-5.yaml
@@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
diff --git a/config/test-6.yaml b/config/test-6.yaml
index 156da84d2..9c43d2b2e 100644
--- a/config/test-6.yaml
+++ b/config/test-6.yaml
@@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
diff --git a/config/test.yaml b/config/test.yaml
index 461e1b4ba..99bf85143 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -164,3 +164,6 @@ views:
local_buffer_update_interval: '5 seconds'
ip_view_expiration: '1 second'
+
+video_editor:
+ enabled: true
diff --git a/scripts/create-transcoding-job.ts b/scripts/create-transcoding-job.ts
index c4b376431..59fc84ad5 100755
--- a/scripts/create-transcoding-job.ts
+++ b/scripts/create-transcoding-job.ts
@@ -1,6 +1,6 @@
import { program } from 'commander'
import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
-import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
+import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
import { CONFIG } from '@server/initializers/config'
import { addTranscodingJob } from '@server/lib/video'
import { VideoState, VideoTranscodingPayload } from '@shared/models'
diff --git a/scripts/print-transcode-command.ts b/scripts/print-transcode-command.ts
index 21667f544..ef671c0aa 100644
--- a/scripts/print-transcode-command.ts
+++ b/scripts/print-transcode-command.ts
@@ -1,8 +1,8 @@
import { program } from 'commander'
import ffmpeg from 'fluent-ffmpeg'
import { exit } from 'process'
-import { buildx264VODCommand, runCommand, TranscodeOptions } from '@server/helpers/ffmpeg-utils'
-import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/video-transcoding-profiles'
+import { buildVODCommand, runCommand, TranscodeVODOptions } from '@server/helpers/ffmpeg'
+import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
program
.arguments('')
@@ -33,12 +33,12 @@ async function run (path: string, cmd: any) {
resolution: +cmd.resolution,
isPortraitMode: false
- } as TranscodeOptions
+ } as TranscodeVODOptions
let command = ffmpeg(options.inputPath)
.output(options.outputPath)
- command = await buildx264VODCommand(command, options)
+ command = await buildVODCommand(command, options)
command.on('start', (cmdline) => {
console.log(cmdline)
diff --git a/server.ts b/server.ts
index 385996470..bb7a0c210 100644
--- a/server.ts
+++ b/server.ts
@@ -42,10 +42,7 @@ try {
import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
-const errorMessage = checkConfig()
-if (errorMessage !== null) {
- throw new Error(errorMessage)
-}
+checkConfig()
// Trust our proxy (IP forwarding...)
app.set('trust proxy', CONFIG.TRUST_PROXY)
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 4e3dd4d80..821ed4ad3 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -256,6 +256,9 @@ function customConfig (): CustomConfig {
}
}
},
+ videoEditor: {
+ enabled: CONFIG.VIDEO_EDITOR.ENABLED
+ },
import: {
videos: {
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
diff --git a/server/controllers/api/videos/editor.ts b/server/controllers/api/videos/editor.ts
new file mode 100644
index 000000000..61e2eb5da
--- /dev/null
+++ b/server/controllers/api/videos/editor.ts
@@ -0,0 +1,120 @@
+import express from 'express'
+import { createAnyReqFiles } from '@server/helpers/express-utils'
+import { CONFIG } from '@server/initializers/config'
+import { MIMETYPES } from '@server/initializers/constants'
+import { JobQueue } from '@server/lib/job-queue'
+import { buildTaskFileFieldname, getTaskFile } from '@server/lib/video-editor'
+import {
+ HttpStatusCode,
+ VideoEditionTaskPayload,
+ VideoEditorCreateEdition,
+ VideoEditorTask,
+ VideoEditorTaskCut,
+ VideoEditorTaskIntro,
+ VideoEditorTaskOutro,
+ VideoEditorTaskWatermark,
+ VideoState
+} from '@shared/models'
+import { asyncMiddleware, authenticate, videosEditorAddEditionValidator } from '../../../middlewares'
+
+const editorRouter = express.Router()
+
+const tasksFiles = createAnyReqFiles(
+ MIMETYPES.VIDEO.MIMETYPE_EXT,
+ CONFIG.STORAGE.TMP_DIR,
+ (req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
+ const body = req.body as VideoEditorCreateEdition
+
+ // Fetch array element
+ const matches = file.fieldname.match(/tasks\[(\d+)\]/)
+ if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
+
+ const indice = parseInt(matches[1])
+ const task = body.tasks[indice]
+
+ if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
+
+ if (
+ [ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
+ file.fieldname === buildTaskFileFieldname(indice)
+ ) {
+ return cb(null, true)
+ }
+
+ return cb(null, false)
+ }
+)
+
+editorRouter.post('/:videoId/editor/edit',
+ authenticate,
+ tasksFiles,
+ asyncMiddleware(videosEditorAddEditionValidator),
+ asyncMiddleware(createEditionTasks)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+ editorRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function createEditionTasks (req: express.Request, res: express.Response) {
+ const files = req.files as Express.Multer.File[]
+ const body = req.body as VideoEditorCreateEdition
+ const video = res.locals.videoAll
+
+ video.state = VideoState.TO_EDIT
+ await video.save()
+
+ const payload = {
+ videoUUID: video.uuid,
+ tasks: body.tasks.map((t, i) => buildTaskPayload(t, i, files))
+ }
+
+ JobQueue.Instance.createJob({ type: 'video-edition', payload })
+
+ return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+const taskPayloadBuilders: {
+ [id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => VideoEditionTaskPayload
+} = {
+ 'add-intro': buildIntroOutroTask,
+ 'add-outro': buildIntroOutroTask,
+ 'cut': buildCutTask,
+ 'add-watermark': buildWatermarkTask
+}
+
+function buildTaskPayload (task: VideoEditorTask, indice: number, files: Express.Multer.File[]): VideoEditionTaskPayload {
+ return taskPayloadBuilders[task.name](task, indice, files)
+}
+
+function buildIntroOutroTask (task: VideoEditorTaskIntro | VideoEditorTaskOutro, indice: number, files: Express.Multer.File[]) {
+ return {
+ name: task.name,
+ options: {
+ file: getTaskFile(files, indice).path
+ }
+ }
+}
+
+function buildCutTask (task: VideoEditorTaskCut) {
+ return {
+ name: task.name,
+ options: {
+ start: task.options.start,
+ end: task.options.end
+ }
+ }
+}
+
+function buildWatermarkTask (task: VideoEditorTaskWatermark, indice: number, files: Express.Multer.File[]) {
+ return {
+ name: task.name,
+ options: {
+ file: getTaskFile(files, indice).path
+ }
+ }
+}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 61a030ba1..a5ae07d95 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -35,6 +35,7 @@ import { VideoModel } from '../../../models/video/video'
import { blacklistRouter } from './blacklist'
import { videoCaptionsRouter } from './captions'
import { videoCommentRouter } from './comment'
+import { editorRouter } from './editor'
import { filesRouter } from './files'
import { videoImportsRouter } from './import'
import { liveRouter } from './live'
@@ -51,6 +52,7 @@ const videosRouter = express.Router()
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter)
+videosRouter.use('/', editorRouter)
videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter)
diff --git a/server/controllers/api/videos/transcoding.ts b/server/controllers/api/videos/transcoding.ts
index fba4545c2..da3ea3c9c 100644
--- a/server/controllers/api/videos/transcoding.ts
+++ b/server/controllers/api/videos/transcoding.ts
@@ -1,5 +1,5 @@
import express from 'express'
-import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
+import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { addTranscodingJob } from '@server/lib/video'
import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
@@ -29,7 +29,7 @@ async function createTranscoding (req: express.Request, res: express.Response) {
const body: VideoTranscodingCreate = req.body
- const { resolution: maxResolution, isPortraitMode, audioStream } = await video.getMaxQualityFileInfo()
+ const { resolution: maxResolution, isPortraitMode, audioStream } = await video.probeMaxQualityFile()
const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
video.state = VideoState.TO_TRANSCODE
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index fd90d9915..3c026ad1f 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -24,7 +24,7 @@ import { HttpStatusCode, VideoCreate, VideoResolution, VideoState } from '@share
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { createReqFiles } from '../../../helpers/express-utils'
-import { ffprobePromise, getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
+import { ffprobePromise, buildFileMetadata, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
import { logger, loggerTagsFactory } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants'
@@ -246,7 +246,7 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
extname: getLowercaseExtension(videoPhysicalFile.filename),
size: videoPhysicalFile.size,
videoStreamingPlaylistId: null,
- metadata: await getMetadataFromFile(videoPhysicalFile.path)
+ metadata: await buildFileMetadata(videoPhysicalFile.path)
})
const probe = await ffprobePromise(videoPhysicalFile.path)
@@ -254,8 +254,8 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
if (await isAudioFile(videoPhysicalFile.path, probe)) {
videoFile.resolution = VideoResolution.H_NOVIDEO
} else {
- videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path, probe)
- videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path, probe)).resolution
+ videoFile.fps = await getVideoStreamFPS(videoPhysicalFile.path, probe)
+ videoFile.resolution = (await getVideoStreamDimensionsInfo(videoPhysicalFile.path, probe)).resolution
}
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
diff --git a/server/helpers/custom-validators/actor-images.ts b/server/helpers/custom-validators/actor-images.ts
index 4fb0b7c70..89f5a2262 100644
--- a/server/helpers/custom-validators/actor-images.ts
+++ b/server/helpers/custom-validators/actor-images.ts
@@ -1,4 +1,5 @@
+import { UploadFilesForCheck } from 'express'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { isFileValid } from './misc'
@@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
.map(v => v.replace('.', ''))
.join('|')
const imageMimeTypesRegex = `image/(${imageMimeTypes})`
-function isActorImageFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], fieldname: string) {
- return isFileValid(files, imageMimeTypesRegex, fieldname, CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max)
+
+function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
+ return isFileValid({
+ files,
+ mimeTypeRegex: imageMimeTypesRegex,
+ field: fieldname,
+ maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
+ })
}
// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index 81a60ee66..c80c86193 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -61,75 +61,43 @@ function isIntOrNull (value: any) {
// ---------------------------------------------------------------------------
-function isFileFieldValid (
- files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
- field: string,
- optional = false
-) {
+function isFileValid (options: {
+ files: UploadFilesForCheck
+
+ maxSize: number | null
+ mimeTypeRegex: string | null
+
+ field?: string
+
+ optional?: boolean // Default false
+}) {
+ const { files, mimeTypeRegex, field, maxSize, optional = false } = options
+
// Should have files
if (!files) return optional
- if (isArray(files)) return optional
- // Should have a file
- const fileArray = files[field]
- if (!fileArray || fileArray.length === 0) {
+ const fileArray = isArray(files)
+ ? files
+ : files[field]
+
+ if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
return optional
}
- // The file should exist
- const file = fileArray[0]
- if (!file || !file.originalname) return false
- return file
-}
-
-function isFileMimeTypeValid (
- files: UploadFilesForCheck,
- mimeTypeRegex: string,
- field: string,
- optional = false
-) {
- // Should have files
- if (!files) return optional
- if (isArray(files)) return optional
-
- // Should have a file
- const fileArray = files[field]
- if (!fileArray || fileArray.length === 0) {
- return optional
- }
-
- // The file should exist
- const file = fileArray[0]
- if (!file || !file.originalname) return false
-
- return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
-}
-
-function isFileValid (
- files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
- mimeTypeRegex: string,
- field: string,
- maxSize: number | null,
- optional = false
-) {
- // Should have files
- if (!files) return optional
- if (isArray(files)) return optional
-
- // Should have a file
- const fileArray = files[field]
- if (!fileArray || fileArray.length === 0) {
- return optional
- }
-
- // The file should exist
+ // The file exists
const file = fileArray[0]
if (!file || !file.originalname) return false
// Check size
if ((maxSize !== null) && file.size > maxSize) return false
- return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
+ if (mimeTypeRegex === null) return true
+
+ return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
+}
+
+function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
+ return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
}
// ---------------------------------------------------------------------------
@@ -204,7 +172,6 @@ export {
areUUIDsValid,
toArray,
toIntArray,
- isFileFieldValid,
- isFileMimeTypeValid,
- isFileValid
+ isFileValid,
+ checkMimetypeRegex
}
diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts
index 4cc7dcaf4..59ba005fe 100644
--- a/server/helpers/custom-validators/video-captions.ts
+++ b/server/helpers/custom-validators/video-captions.ts
@@ -1,5 +1,6 @@
-import { getFileSize } from '@shared/extra-utils'
+import { UploadFilesForCheck } from 'express'
import { readFile } from 'fs-extra'
+import { getFileSize } from '@shared/extra-utils'
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
import { exists, isFileValid } from './misc'
@@ -11,8 +12,13 @@ const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT
.concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
.map(m => `(${m})`)
.join('|')
-function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
- return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
+function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
+ return isFileValid({
+ files,
+ mimeTypeRegex: videoCaptionTypesRegex,
+ field,
+ maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
+ })
}
async function isVTTFileValid (filePath: string) {
diff --git a/server/helpers/custom-validators/video-editor.ts b/server/helpers/custom-validators/video-editor.ts
new file mode 100644
index 000000000..09238675e
--- /dev/null
+++ b/server/helpers/custom-validators/video-editor.ts
@@ -0,0 +1,52 @@
+import validator from 'validator'
+import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
+import { buildTaskFileFieldname } from '@server/lib/video-editor'
+import { VideoEditorTask } from '@shared/models'
+import { isArray } from './misc'
+import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos'
+
+function isValidEditorTasksArray (tasks: any) {
+ if (!isArray(tasks)) return false
+
+ return tasks.length >= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.min &&
+ tasks.length <= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.max
+}
+
+function isEditorCutTaskValid (task: VideoEditorTask) {
+ if (task.name !== 'cut') return false
+ if (!task.options) return false
+
+ const { start, end } = task.options
+ if (!start && !end) return false
+
+ if (start && !validator.isInt(start + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
+ if (end && !validator.isInt(end + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
+
+ if (!start || !end) return true
+
+ return parseInt(start + '') < parseInt(end + '')
+}
+
+function isEditorTaskAddIntroOutroValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
+ const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
+
+ return (task.name === 'add-intro' || task.name === 'add-outro') &&
+ file && isVideoFileMimeTypeValid([ file ], null)
+}
+
+function isEditorTaskAddWatermarkValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
+ const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
+
+ return task.name === 'add-watermark' &&
+ file && isVideoImageValid([ file ], null, true)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ isValidEditorTasksArray,
+
+ isEditorCutTaskValid,
+ isEditorTaskAddIntroOutroValid,
+ isEditorTaskAddWatermarkValid
+}
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index dbf6a3504..af93aea56 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -1,4 +1,5 @@
import 'multer'
+import { UploadFilesForCheck } from 'express'
import validator from 'validator'
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
import { exists, isFileValid } from './misc'
@@ -25,8 +26,14 @@ const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
.concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
.map(m => `(${m})`)
.join('|')
-function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
- return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
+function isVideoImportTorrentFile (files: UploadFilesForCheck) {
+ return isFileValid({
+ files,
+ mimeTypeRegex: videoTorrentImportRegex,
+ field: 'torrentfile',
+ maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
+ optional: true
+ })
}
// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index e526c4284..ca5f70fdc 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -13,7 +13,7 @@ import {
VIDEO_RATE_TYPES,
VIDEO_STATES
} from '../../initializers/constants'
-import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc'
+import { exists, isArray, isDateValid, isFileValid } from './misc'
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
@@ -66,7 +66,7 @@ function isVideoTagValid (tag: string) {
return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
}
-function isVideoTagsValid (tags: string[]) {
+function areVideoTagsValid (tags: string[]) {
return tags === null || (
isArray(tags) &&
validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) &&
@@ -86,8 +86,13 @@ function isVideoFileExtnameValid (value: string) {
return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
}
-function isVideoFileMimeTypeValid (files: UploadFilesForCheck) {
- return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile')
+function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
+ return isFileValid({
+ files,
+ mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
+ field,
+ maxSize: null
+ })
}
const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
@@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
.join('|')
const videoImageTypesRegex = `image/(${videoImageTypes})`
-function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
- return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true)
+function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
+ return isFileValid({
+ files,
+ mimeTypeRegex: videoImageTypesRegex,
+ field,
+ maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
+ optional
+ })
}
function isVideoPrivacyValid (value: number) {
@@ -144,7 +155,7 @@ export {
isVideoDescriptionValid,
isVideoFileInfoHashValid,
isVideoNameValid,
- isVideoTagsValid,
+ areVideoTagsValid,
isVideoFPSResolutionValid,
isScheduleVideoUpdatePrivacyValid,
isVideoOriginallyPublishedAtValid,
@@ -160,7 +171,7 @@ export {
isVideoPrivacyValid,
isVideoFileResolutionValid,
isVideoFileSizeValid,
- isVideoImage,
+ isVideoImageValid,
isVideoSupportValid,
isVideoFilterValid
}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 780fd6345..08f77966f 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -1,9 +1,9 @@
import express, { RequestHandler } from 'express'
import multer, { diskStorage } from 'multer'
+import { getLowercaseExtension } from '@shared/core-utils'
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
import { CONFIG } from '../initializers/config'
import { REMOTE_SCHEME } from '../initializers/constants'
-import { getLowercaseExtension } from '@shared/core-utils'
import { isArray } from './custom-validators/misc'
import { logger } from './logger'
import { deleteFileAndCatch, generateRandomString } from './utils'
@@ -75,29 +75,8 @@ function createReqFiles (
cb(null, destinations[file.fieldname])
},
- filename: async (req, file, cb) => {
- let extension: string
- const fileExtension = getLowercaseExtension(file.originalname)
- const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
-
- // Take the file extension if we don't understand the mime type
- if (!extensionFromMimetype) {
- extension = fileExtension
- } else {
- // Take the first available extension for this mimetype
- extension = extensionFromMimetype
- }
-
- let randomString = ''
-
- try {
- randomString = await generateRandomString(16)
- } catch (err) {
- logger.error('Cannot generate random string for file name.', { err })
- randomString = 'fake-random-string'
- }
-
- cb(null, randomString + extension)
+ filename: (req, file, cb) => {
+ return generateReqFilename(file, mimeTypes, cb)
}
})
@@ -112,6 +91,24 @@ function createReqFiles (
return multer({ storage }).fields(fields)
}
+function createAnyReqFiles (
+ mimeTypes: { [id: string]: string | string[] },
+ destinationDirectory: string,
+ fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void
+): RequestHandler {
+ const storage = diskStorage({
+ destination: (req, file, cb) => {
+ cb(null, destinationDirectory)
+ },
+
+ filename: (req, file, cb) => {
+ return generateReqFilename(file, mimeTypes, cb)
+ }
+ })
+
+ return multer({ storage, fileFilter }).any()
+}
+
function isUserAbleToSearchRemoteURI (res: express.Response) {
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
@@ -128,9 +125,41 @@ function getCountVideos (req: express.Request) {
export {
buildNSFWFilter,
getHostWithPort,
+ createAnyReqFiles,
isUserAbleToSearchRemoteURI,
badRequest,
createReqFiles,
cleanUpReqFiles,
getCountVideos
}
+
+// ---------------------------------------------------------------------------
+
+async function generateReqFilename (
+ file: Express.Multer.File,
+ mimeTypes: { [id: string]: string | string[] },
+ cb: (err: Error, name: string) => void
+) {
+ let extension: string
+ const fileExtension = getLowercaseExtension(file.originalname)
+ const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
+
+ // Take the file extension if we don't understand the mime type
+ if (!extensionFromMimetype) {
+ extension = fileExtension
+ } else {
+ // Take the first available extension for this mimetype
+ extension = extensionFromMimetype
+ }
+
+ let randomString = ''
+
+ try {
+ randomString = await generateRandomString(16)
+ } catch (err) {
+ logger.error('Cannot generate random string for file name.', { err })
+ randomString = 'fake-random-string'
+ }
+
+ cb(null, randomString + extension)
+}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
deleted file mode 100644
index 78ee5fa7f..000000000
--- a/server/helpers/ffmpeg-utils.ts
+++ /dev/null
@@ -1,781 +0,0 @@
-import { Job } from 'bull'
-import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
-import { readFile, remove, writeFile } from 'fs-extra'
-import { dirname, join } from 'path'
-import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
-import { pick } from '@shared/core-utils'
-import {
- AvailableEncoders,
- EncoderOptions,
- EncoderOptionsBuilder,
- EncoderOptionsBuilderParams,
- EncoderProfile,
- VideoResolution
-} from '../../shared/models/videos'
-import { CONFIG } from '../initializers/config'
-import { execPromise, promisify0 } from './core-utils'
-import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
-import { processImage } from './image-utils'
-import { logger, loggerTagsFactory } from './logger'
-
-const lTags = loggerTagsFactory('ffmpeg')
-
-/**
- *
- * Functions that run transcoding/muxing ffmpeg processes
- * Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
- *
- */
-
-// ---------------------------------------------------------------------------
-// Encoder options
-// ---------------------------------------------------------------------------
-
-type StreamType = 'audio' | 'video'
-
-// ---------------------------------------------------------------------------
-// Encoders support
-// ---------------------------------------------------------------------------
-
-// Detect supported encoders by ffmpeg
-let supportedEncoders: Map
-async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise