Add basic video editor support
This commit is contained in:
parent
a24bf4dc65
commit
c729caf6cc
|
@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
resolutions: {}
|
resolutions: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoEditor: {
|
||||||
|
enabled: null
|
||||||
|
},
|
||||||
autoBlacklist: {
|
autoBlacklist: {
|
||||||
videos: {
|
videos: {
|
||||||
ofUsers: {
|
ofUsers: {
|
||||||
|
|
|
@ -192,4 +192,29 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row mt-2"> <!-- video editor grid -->
|
||||||
|
<div class="form-group col-12 col-lg-4 col-xl-3">
|
||||||
|
<div i18n class="inner-form-title">VIDEO EDITOR</div>
|
||||||
|
<div i18n class="inner-form-description">
|
||||||
|
Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
|
||||||
|
|
||||||
|
<ng-container formGroupName="videoEditor">
|
||||||
|
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||||
|
<my-peertube-checkbox
|
||||||
|
inputName="videoEditorEnabled" formControlName="enabled"
|
||||||
|
i18n-labelText labelText="Enable video editor"
|
||||||
|
>
|
||||||
|
<ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
|
||||||
|
<span i18n>⚠️ You need to enable transcoding first to enable video editor</span>
|
||||||
|
</ng-container>
|
||||||
|
</my-peertube-checkbox>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
|
@ -71,6 +71,8 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkTranscodingFields () {
|
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 hlsControl = this.form.get('transcoding.hls.enabled')
|
||||||
const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
|
const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
|
||||||
|
|
||||||
|
@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||||
webtorrentControl.enable()
|
webtorrentControl.enable()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
transcodingControl.valueChanges
|
||||||
|
.subscribe(newValue => {
|
||||||
|
if (newValue === false) {
|
||||||
|
videoEditorControl.setValue(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
@use '_variables' as *;
|
@use '_variables' as *;
|
||||||
@use '_mixins' as *;
|
@use '_mixins' as *;
|
||||||
|
|
||||||
my-embed {
|
my-embed {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
|
||||||
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
|
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
|
||||||
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
|
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
|
||||||
import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
|
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'
|
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -204,6 +204,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
|
||||||
|
|
||||||
private buildActions () {
|
private buildActions () {
|
||||||
this.videoActions = [
|
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`,
|
label: $localize`Display live information`,
|
||||||
handler: ({ video }) => this.displayLiveInformation(video),
|
handler: ({ video }) => this.displayLiveInformation(video),
|
||||||
|
|
2
client/src/app/+video-editor/edit/index.ts
Normal file
2
client/src/app/+video-editor/edit/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './video-editor-edit.component'
|
||||||
|
export * from './video-editor-edit.resolver'
|
|
@ -0,0 +1,88 @@
|
||||||
|
<div class="margin-content">
|
||||||
|
<h1 class="title-page title-page-single" i18n>Edit {{ video.name }}</h1>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<form role="form" [formGroup]="form">
|
||||||
|
|
||||||
|
<div class="section cut" formGroupName="cut">
|
||||||
|
<h2 i18n>CUT VIDEO</h2>
|
||||||
|
|
||||||
|
<div i18n class="description">Set a new start/end.</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="cutStart">New start</label>
|
||||||
|
<my-timestamp-input inputName="cutStart" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="start"></my-timestamp-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label i18n for="cutEnd">New end</label>
|
||||||
|
<my-timestamp-input inputName="cutEnd" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="end"></my-timestamp-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" formGroupName="add-intro">
|
||||||
|
<h2 i18n>ADD INTRO</h2>
|
||||||
|
|
||||||
|
<div i18n class="description">Concatenate a file at the beginning of the video.</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<my-reactive-file
|
||||||
|
formControlName="file" inputName="addIntroFile" i18n-inputLabel inputLabel="Select the intro video file"
|
||||||
|
[extensions]="videoExtensions" [displayFilename]="true"
|
||||||
|
[ngbTooltip]="getIntroOutroTooltip()"
|
||||||
|
></my-reactive-file>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" formGroupName="add-outro">
|
||||||
|
<h2 i18n>ADD OUTRO</h2>
|
||||||
|
|
||||||
|
<div i18n class="description">Concatenate a file at the end of the video.</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<my-reactive-file
|
||||||
|
formControlName="file" inputName="addOutroFile" i18n-inputLabel inputLabel="Select the outro video file"
|
||||||
|
[extensions]="videoExtensions" [displayFilename]="true"
|
||||||
|
[ngbTooltip]="getIntroOutroTooltip()"
|
||||||
|
></my-reactive-file>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" formGroupName="add-watermark">
|
||||||
|
<h2 i18n>ADD WATERMARK</h2>
|
||||||
|
|
||||||
|
<div i18n class="description">Add a watermark image to the video.</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<my-reactive-file
|
||||||
|
formControlName="file" inputName="addWatermarkFile" i18n-inputLabel inputLabel="Select watermark image file"
|
||||||
|
[extensions]="imageExtensions" [displayFilename]="true"
|
||||||
|
[ngbTooltip]="getWatermarkTooltip()"
|
||||||
|
></my-reactive-file>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-button
|
||||||
|
className="orange-button" i18n-label label="Run video edition" icon="circle-tick"
|
||||||
|
(click)="runEdition()" (keydown.enter)="runEdition()"
|
||||||
|
[disabled]="!form.valid || isRunningEdition || noEdition()"
|
||||||
|
></my-button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="information">
|
||||||
|
<div>
|
||||||
|
<label i18n>Video before edition</label>
|
||||||
|
<my-embed [video]="video"></my-embed>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!noEdition()">
|
||||||
|
<label i18n>Edition tasks:</label>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li *ngFor="let task of getTasksSummary()">{{ task }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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;
|
||||||
|
}
|
202
client/src/app/+video-editor/edit/video-editor-edit.component.ts
Normal file
202
client/src/app/+video-editor/edit/video-editor-edit.component.ts
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
import { Component, OnInit } from '@angular/core'
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
|
import { ConfirmService, Notifier, ServerService } from '@app/core'
|
||||||
|
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
|
||||||
|
import { Video, VideoDetails } from '@app/shared/shared-main'
|
||||||
|
import { LoadingBarService } from '@ngx-loading-bar/core'
|
||||||
|
import { secondsToTime } from '@shared/core-utils'
|
||||||
|
import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
|
||||||
|
import { VideoEditorService } from '../shared'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-video-editor-edit',
|
||||||
|
templateUrl: './video-editor-edit.component.html',
|
||||||
|
styleUrls: [ './video-editor-edit.component.scss' ]
|
||||||
|
})
|
||||||
|
export class VideoEditorEditComponent extends FormReactive implements OnInit {
|
||||||
|
isRunningEdition = false
|
||||||
|
|
||||||
|
video: VideoDetails
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
protected formValidatorService: FormValidatorService,
|
||||||
|
private serverService: ServerService,
|
||||||
|
private notifier: Notifier,
|
||||||
|
private router: Router,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private videoEditorService: VideoEditorService,
|
||||||
|
private loadingBar: LoadingBarService,
|
||||||
|
private confirmService: ConfirmService
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit () {
|
||||||
|
this.video = this.route.snapshot.data.video
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
cut: {
|
||||||
|
start: 0,
|
||||||
|
end: this.video.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildForm({
|
||||||
|
cut: {
|
||||||
|
start: null,
|
||||||
|
end: null
|
||||||
|
},
|
||||||
|
'add-intro': {
|
||||||
|
file: null
|
||||||
|
},
|
||||||
|
'add-outro': {
|
||||||
|
file: null
|
||||||
|
},
|
||||||
|
'add-watermark': {
|
||||||
|
file: null
|
||||||
|
}
|
||||||
|
}, defaultValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
get videoExtensions () {
|
||||||
|
return this.serverService.getHTMLConfig().video.file.extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
get imageExtensions () {
|
||||||
|
return this.serverService.getHTMLConfig().video.image.extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
async runEdition () {
|
||||||
|
if (this.isRunningEdition) return
|
||||||
|
|
||||||
|
const title = $localize`Are you sure you want to edit "${this.video.name}"?`
|
||||||
|
const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
|
||||||
|
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
|
const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
|
||||||
|
$localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
|
||||||
|
|
||||||
|
if (await this.confirmService.confirm(confirmHTML, title) !== true) return
|
||||||
|
|
||||||
|
this.isRunningEdition = true
|
||||||
|
|
||||||
|
const tasks = this.buildTasks()
|
||||||
|
|
||||||
|
this.loadingBar.useRef().start()
|
||||||
|
|
||||||
|
return this.videoEditorService.editVideo(this.video.uuid, tasks)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.notifier.success($localize`Video updated.`)
|
||||||
|
this.router.navigateByUrl(Video.buildWatchUrl(this.video))
|
||||||
|
},
|
||||||
|
|
||||||
|
error: err => {
|
||||||
|
this.loadingBar.useRef().complete()
|
||||||
|
this.isRunningEdition = false
|
||||||
|
this.notifier.error(err.message)
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getIntroOutroTooltip () {
|
||||||
|
return $localize`(extensions: ${this.videoExtensions.join(', ')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
getWatermarkTooltip () {
|
||||||
|
return $localize`(extensions: ${this.imageExtensions.join(', ')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
noEdition () {
|
||||||
|
return this.buildTasks().length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
getTasksSummary () {
|
||||||
|
const tasks = this.buildTasks()
|
||||||
|
|
||||||
|
return tasks.map(t => {
|
||||||
|
if (t.name === 'add-intro') {
|
||||||
|
return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.name === 'add-outro') {
|
||||||
|
return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.name === 'add-watermark') {
|
||||||
|
return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.name === 'cut') {
|
||||||
|
const { start, end } = t.options
|
||||||
|
|
||||||
|
if (start !== undefined && end !== undefined) {
|
||||||
|
return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start !== undefined) {
|
||||||
|
return $localize`Video will begin at ${secondsToTime(start)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end !== undefined) {
|
||||||
|
return $localize`Video will stop at ${secondsToTime(end)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilename (obj: any) {
|
||||||
|
return obj.name
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTasks () {
|
||||||
|
const tasks: VideoEditorTask[] = []
|
||||||
|
const value = this.form.value
|
||||||
|
|
||||||
|
const cut = value['cut']
|
||||||
|
if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
|
||||||
|
|
||||||
|
const options: VideoEditorTaskCut['options'] = {}
|
||||||
|
if (cut['start'] !== 0) options.start = cut['start']
|
||||||
|
if (cut['end'] !== this.video.duration) options.end = cut['end']
|
||||||
|
|
||||||
|
tasks.push({
|
||||||
|
name: 'cut',
|
||||||
|
options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['add-intro']?.['file']) {
|
||||||
|
tasks.push({
|
||||||
|
name: 'add-intro',
|
||||||
|
options: {
|
||||||
|
file: value['add-intro']['file']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['add-outro']?.['file']) {
|
||||||
|
tasks.push({
|
||||||
|
name: 'add-outro',
|
||||||
|
options: {
|
||||||
|
file: value['add-outro']['file']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['add-watermark']?.['file']) {
|
||||||
|
tasks.push({
|
||||||
|
name: 'add-watermark',
|
||||||
|
options: {
|
||||||
|
file: value['add-watermark']['file']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
|
||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
|
||||||
|
import { VideoService } from '@app/shared/shared-main'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VideoEditorEditResolver implements Resolve<any> {
|
||||||
|
constructor (
|
||||||
|
private videoService: VideoService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve (route: ActivatedRouteSnapshot) {
|
||||||
|
const videoId: string = route.params['videoId']
|
||||||
|
|
||||||
|
return this.videoService.getVideo({ videoId })
|
||||||
|
}
|
||||||
|
}
|
1
client/src/app/+video-editor/index.ts
Normal file
1
client/src/app/+video-editor/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './video-editor.module'
|
1
client/src/app/+video-editor/shared/index.ts
Normal file
1
client/src/app/+video-editor/shared/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './video-editor.service'
|
28
client/src/app/+video-editor/shared/video-editor.service.ts
Normal file
28
client/src/app/+video-editor/shared/video-editor.service.ts
Normal file
|
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
30
client/src/app/+video-editor/video-editor-routing.module.ts
Normal file
30
client/src/app/+video-editor/video-editor-routing.module.ts
Normal file
|
@ -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 {}
|
27
client/src/app/+video-editor/video-editor.module.ts
Normal file
27
client/src/app/+video-editor/video-editor.module.ts
Normal file
|
@ -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 { }
|
|
@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
|
||||||
playlist: false,
|
playlist: false,
|
||||||
download: true,
|
download: true,
|
||||||
update: true,
|
update: true,
|
||||||
|
editor: true,
|
||||||
blacklist: true,
|
blacklist: true,
|
||||||
delete: true,
|
delete: true,
|
||||||
report: true,
|
report: true,
|
||||||
|
|
|
@ -14,6 +14,10 @@
|
||||||
The video is being transcoded, it may not work properly.
|
The video is being transcoded, it may not work properly.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
|
||||||
|
The video is being edited, it may not work properly.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
|
<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
|
||||||
The video is being moved to an external server, it may not work properly.
|
The video is being moved to an external server, it may not work properly.
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,6 +14,10 @@ export class VideoAlertComponent {
|
||||||
return this.video && this.video.state.id === VideoState.TO_TRANSCODE
|
return this.video && this.video.state.id === VideoState.TO_TRANSCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isVideoToEdit () {
|
||||||
|
return this.video && this.video.state.id === VideoState.TO_EDIT
|
||||||
|
}
|
||||||
|
|
||||||
isVideoTranscodingFailed () {
|
isVideoTranscodingFailed () {
|
||||||
return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
|
return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,6 +143,12 @@ const routes: Routes = [
|
||||||
canActivateChild: [ MetaGuard ]
|
canActivateChild: [ MetaGuard ]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'video-editor',
|
||||||
|
loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
|
||||||
|
canActivateChild: [ MetaGuard ]
|
||||||
|
},
|
||||||
|
|
||||||
// Matches /@:actorName
|
// Matches /@:actorName
|
||||||
{
|
{
|
||||||
matcher: (url): UrlMatchResult => {
|
matcher: (url): UrlMatchResult => {
|
||||||
|
|
|
@ -24,7 +24,7 @@ export abstract class FormReactive {
|
||||||
this.formErrors = formErrors
|
this.formErrors = formErrors
|
||||||
this.validationMessages = validationMessages
|
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
|
// FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
|
||||||
await this.waitPendingCheck()
|
await this.waitPendingCheck()
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ export class FormValidatorService {
|
||||||
|
|
||||||
if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
|
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 ]
|
if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
|
||||||
else group[name] = [ defaultValue ]
|
else group[name] = [ defaultValue ]
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<p-inputMask
|
<p-inputMask
|
||||||
[disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
|
[disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
|
||||||
mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
|
[ngClass]="{ 'border-disabled': disableBorder }"
|
||||||
|
mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
|
||||||
></p-inputMask>
|
></p-inputMask>
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
@use '_variables' as *;
|
@use '_variables' as *;
|
||||||
|
@use '_mixins' as *;
|
||||||
|
|
||||||
p-inputmask {
|
p-inputmask {
|
||||||
::ng-deep input {
|
::ng-deep input {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
border: 0;
|
|
||||||
|
|
||||||
&:focus-within,
|
&:focus-within,
|
||||||
&:focus {
|
&:focus {
|
||||||
|
@ -16,4 +16,16 @@ p-inputmask {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.border-disabled {
|
||||||
|
::ng-deep input {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.border-disabled) {
|
||||||
|
::ng-deep input {
|
||||||
|
@include peertube-input-text(80px);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
|
||||||
@Input() maxTimestamp: number
|
@Input() maxTimestamp: number
|
||||||
@Input() timestamp: number
|
@Input() timestamp: number
|
||||||
@Input() disabled = false
|
@Input() disabled = false
|
||||||
|
@Input() inputName: string
|
||||||
|
@Input() disableBorder = true
|
||||||
|
|
||||||
@Output() inputBlur = new EventEmitter()
|
@Output() inputBlur = new EventEmitter()
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
|
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 { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
|
||||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { VideoCaption } from '@shared/models'
|
import { VideoCaption, VideoState } from '@shared/models'
|
||||||
import {
|
import {
|
||||||
Actor,
|
Actor,
|
||||||
DropdownAction,
|
DropdownAction,
|
||||||
|
@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
|
||||||
liveInfo?: boolean
|
liveInfo?: boolean
|
||||||
removeFiles?: boolean
|
removeFiles?: boolean
|
||||||
transcoding?: boolean
|
transcoding?: boolean
|
||||||
|
editor?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
mute: true,
|
mute: true,
|
||||||
liveInfo: false,
|
liveInfo: false,
|
||||||
removeFiles: false,
|
removeFiles: false,
|
||||||
transcoding: false
|
transcoding: false,
|
||||||
|
editor: true
|
||||||
}
|
}
|
||||||
@Input() placement = 'left'
|
@Input() placement = 'left'
|
||||||
|
|
||||||
|
@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
private videoBlocklistService: VideoBlockService,
|
private videoBlocklistService: VideoBlockService,
|
||||||
private screenService: ScreenService,
|
private screenService: ScreenService,
|
||||||
private videoService: VideoService,
|
private videoService: VideoService,
|
||||||
private redundancyService: RedundancyService
|
private redundancyService: RedundancyService,
|
||||||
|
private serverService: ServerService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
get user () {
|
get user () {
|
||||||
|
@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
return this.video.isUpdatableBy(this.user)
|
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 () {
|
isVideoRemovable () {
|
||||||
return this.video.isRemovableBy(this.user)
|
return this.video.isRemovableBy(this.user)
|
||||||
}
|
}
|
||||||
|
@ -329,6 +338,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
||||||
iconName: 'edit',
|
iconName: 'edit',
|
||||||
isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
|
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`,
|
label: $localize`Block`,
|
||||||
handler: () => this.showBlockModal(),
|
handler: () => this.showBlockModal(),
|
||||||
|
|
|
@ -195,6 +195,10 @@ export class VideoMiniatureComponent implements OnInit {
|
||||||
return $localize`To import`
|
return $localize`To import`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (video.state.id === VideoState.TO_EDIT) {
|
||||||
|
return $localize`To edit`
|
||||||
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -425,6 +425,10 @@ live:
|
||||||
1440p: false
|
1440p: false
|
||||||
2160p: false
|
2160p: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
# Enable video edition by users (cut, add intro/outro, add watermark etc)
|
||||||
|
enabled: false
|
||||||
|
|
||||||
import:
|
import:
|
||||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||||
videos:
|
videos:
|
||||||
|
|
|
@ -433,6 +433,10 @@ live:
|
||||||
1440p: false
|
1440p: false
|
||||||
2160p: false
|
2160p: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
# Enable video edition by users (cut, add intro/outro, add watermark etc)
|
||||||
|
enabled: false
|
||||||
|
|
||||||
import:
|
import:
|
||||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||||
videos:
|
videos:
|
||||||
|
|
|
@ -37,6 +37,9 @@ signup:
|
||||||
transcoding:
|
transcoding:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
live:
|
live:
|
||||||
rtmp:
|
rtmp:
|
||||||
port: 1936
|
port: 1936
|
||||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
||||||
|
|
||||||
transcoding:
|
transcoding:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
enabled: false
|
||||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
||||||
|
|
||||||
transcoding:
|
transcoding:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
enabled: false
|
||||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
||||||
|
|
||||||
transcoding:
|
transcoding:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
enabled: false
|
||||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
||||||
|
|
||||||
transcoding:
|
transcoding:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
enabled: false
|
||||||
|
|
|
@ -164,3 +164,6 @@ views:
|
||||||
|
|
||||||
local_buffer_update_interval: '5 seconds'
|
local_buffer_update_interval: '5 seconds'
|
||||||
ip_view_expiration: '1 second'
|
ip_view_expiration: '1 second'
|
||||||
|
|
||||||
|
video_editor:
|
||||||
|
enabled: true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { program } from 'commander'
|
import { program } from 'commander'
|
||||||
import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
|
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 { CONFIG } from '@server/initializers/config'
|
||||||
import { addTranscodingJob } from '@server/lib/video'
|
import { addTranscodingJob } from '@server/lib/video'
|
||||||
import { VideoState, VideoTranscodingPayload } from '@shared/models'
|
import { VideoState, VideoTranscodingPayload } from '@shared/models'
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { program } from 'commander'
|
import { program } from 'commander'
|
||||||
import ffmpeg from 'fluent-ffmpeg'
|
import ffmpeg from 'fluent-ffmpeg'
|
||||||
import { exit } from 'process'
|
import { exit } from 'process'
|
||||||
import { buildx264VODCommand, runCommand, TranscodeOptions } from '@server/helpers/ffmpeg-utils'
|
import { buildVODCommand, runCommand, TranscodeVODOptions } from '@server/helpers/ffmpeg'
|
||||||
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/video-transcoding-profiles'
|
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
|
||||||
|
|
||||||
program
|
program
|
||||||
.arguments('<path>')
|
.arguments('<path>')
|
||||||
|
@ -33,12 +33,12 @@ async function run (path: string, cmd: any) {
|
||||||
|
|
||||||
resolution: +cmd.resolution,
|
resolution: +cmd.resolution,
|
||||||
isPortraitMode: false
|
isPortraitMode: false
|
||||||
} as TranscodeOptions
|
} as TranscodeVODOptions
|
||||||
|
|
||||||
let command = ffmpeg(options.inputPath)
|
let command = ffmpeg(options.inputPath)
|
||||||
.output(options.outputPath)
|
.output(options.outputPath)
|
||||||
|
|
||||||
command = await buildx264VODCommand(command, options)
|
command = await buildVODCommand(command, options)
|
||||||
|
|
||||||
command.on('start', (cmdline) => {
|
command.on('start', (cmdline) => {
|
||||||
console.log(cmdline)
|
console.log(cmdline)
|
||||||
|
|
|
@ -42,10 +42,7 @@ try {
|
||||||
|
|
||||||
import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
|
import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
|
||||||
|
|
||||||
const errorMessage = checkConfig()
|
checkConfig()
|
||||||
if (errorMessage !== null) {
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trust our proxy (IP forwarding...)
|
// Trust our proxy (IP forwarding...)
|
||||||
app.set('trust proxy', CONFIG.TRUST_PROXY)
|
app.set('trust proxy', CONFIG.TRUST_PROXY)
|
||||||
|
|
|
@ -256,6 +256,9 @@ function customConfig (): CustomConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoEditor: {
|
||||||
|
enabled: CONFIG.VIDEO_EDITOR.ENABLED
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
|
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
|
||||||
|
|
120
server/controllers/api/videos/editor.ts
Normal file
120
server/controllers/api/videos/editor.ts
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,6 +35,7 @@ import { VideoModel } from '../../../models/video/video'
|
||||||
import { blacklistRouter } from './blacklist'
|
import { blacklistRouter } from './blacklist'
|
||||||
import { videoCaptionsRouter } from './captions'
|
import { videoCaptionsRouter } from './captions'
|
||||||
import { videoCommentRouter } from './comment'
|
import { videoCommentRouter } from './comment'
|
||||||
|
import { editorRouter } from './editor'
|
||||||
import { filesRouter } from './files'
|
import { filesRouter } from './files'
|
||||||
import { videoImportsRouter } from './import'
|
import { videoImportsRouter } from './import'
|
||||||
import { liveRouter } from './live'
|
import { liveRouter } from './live'
|
||||||
|
@ -51,6 +52,7 @@ const videosRouter = express.Router()
|
||||||
videosRouter.use('/', blacklistRouter)
|
videosRouter.use('/', blacklistRouter)
|
||||||
videosRouter.use('/', rateVideoRouter)
|
videosRouter.use('/', rateVideoRouter)
|
||||||
videosRouter.use('/', videoCommentRouter)
|
videosRouter.use('/', videoCommentRouter)
|
||||||
|
videosRouter.use('/', editorRouter)
|
||||||
videosRouter.use('/', videoCaptionsRouter)
|
videosRouter.use('/', videoCaptionsRouter)
|
||||||
videosRouter.use('/', videoImportsRouter)
|
videosRouter.use('/', videoImportsRouter)
|
||||||
videosRouter.use('/', ownershipVideoRouter)
|
videosRouter.use('/', ownershipVideoRouter)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import express from 'express'
|
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 { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
import { addTranscodingJob } from '@server/lib/video'
|
import { addTranscodingJob } from '@server/lib/video'
|
||||||
import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
|
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 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 ])
|
const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
|
||||||
|
|
||||||
video.state = VideoState.TO_TRANSCODE
|
video.state = VideoState.TO_TRANSCODE
|
||||||
|
|
|
@ -24,7 +24,7 @@ import { HttpStatusCode, VideoCreate, VideoResolution, VideoState } from '@share
|
||||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||||
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
||||||
import { createReqFiles } from '../../../helpers/express-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 { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||||
import { CONFIG } from '../../../initializers/config'
|
import { CONFIG } from '../../../initializers/config'
|
||||||
import { MIMETYPES } from '../../../initializers/constants'
|
import { MIMETYPES } from '../../../initializers/constants'
|
||||||
|
@ -246,7 +246,7 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
|
||||||
extname: getLowercaseExtension(videoPhysicalFile.filename),
|
extname: getLowercaseExtension(videoPhysicalFile.filename),
|
||||||
size: videoPhysicalFile.size,
|
size: videoPhysicalFile.size,
|
||||||
videoStreamingPlaylistId: null,
|
videoStreamingPlaylistId: null,
|
||||||
metadata: await getMetadataFromFile(videoPhysicalFile.path)
|
metadata: await buildFileMetadata(videoPhysicalFile.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
const probe = await ffprobePromise(videoPhysicalFile.path)
|
const probe = await ffprobePromise(videoPhysicalFile.path)
|
||||||
|
@ -254,8 +254,8 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
|
||||||
if (await isAudioFile(videoPhysicalFile.path, probe)) {
|
if (await isAudioFile(videoPhysicalFile.path, probe)) {
|
||||||
videoFile.resolution = VideoResolution.H_NOVIDEO
|
videoFile.resolution = VideoResolution.H_NOVIDEO
|
||||||
} else {
|
} else {
|
||||||
videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path, probe)
|
videoFile.fps = await getVideoStreamFPS(videoPhysicalFile.path, probe)
|
||||||
videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path, probe)).resolution
|
videoFile.resolution = (await getVideoStreamDimensionsInfo(videoPhysicalFile.path, probe)).resolution
|
||||||
}
|
}
|
||||||
|
|
||||||
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
|
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
import { UploadFilesForCheck } from 'express'
|
||||||
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
|
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
|
||||||
import { isFileValid } from './misc'
|
import { isFileValid } from './misc'
|
||||||
|
|
||||||
|
@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
|
||||||
.map(v => v.replace('.', ''))
|
.map(v => v.replace('.', ''))
|
||||||
.join('|')
|
.join('|')
|
||||||
const imageMimeTypesRegex = `image/(${imageMimeTypes})`
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -61,75 +61,43 @@ function isIntOrNull (value: any) {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function isFileFieldValid (
|
function isFileValid (options: {
|
||||||
files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
|
files: UploadFilesForCheck
|
||||||
field: string,
|
|
||||||
optional = false
|
maxSize: number | null
|
||||||
) {
|
mimeTypeRegex: string | null
|
||||||
|
|
||||||
|
field?: string
|
||||||
|
|
||||||
|
optional?: boolean // Default false
|
||||||
|
}) {
|
||||||
|
const { files, mimeTypeRegex, field, maxSize, optional = false } = options
|
||||||
|
|
||||||
// Should have files
|
// Should have files
|
||||||
if (!files) return optional
|
if (!files) return optional
|
||||||
if (isArray(files)) return optional
|
|
||||||
|
|
||||||
// Should have a file
|
const fileArray = isArray(files)
|
||||||
const fileArray = files[field]
|
? files
|
||||||
if (!fileArray || fileArray.length === 0) {
|
: files[field]
|
||||||
|
|
||||||
|
if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
|
||||||
return optional
|
return optional
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file should exist
|
// The file exists
|
||||||
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
|
|
||||||
const file = fileArray[0]
|
const file = fileArray[0]
|
||||||
if (!file || !file.originalname) return false
|
if (!file || !file.originalname) return false
|
||||||
|
|
||||||
// Check size
|
// Check size
|
||||||
if ((maxSize !== null) && file.size > maxSize) return false
|
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,
|
areUUIDsValid,
|
||||||
toArray,
|
toArray,
|
||||||
toIntArray,
|
toIntArray,
|
||||||
isFileFieldValid,
|
isFileValid,
|
||||||
isFileMimeTypeValid,
|
checkMimetypeRegex
|
||||||
isFileValid
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { getFileSize } from '@shared/extra-utils'
|
import { UploadFilesForCheck } from 'express'
|
||||||
import { readFile } from 'fs-extra'
|
import { readFile } from 'fs-extra'
|
||||||
|
import { getFileSize } from '@shared/extra-utils'
|
||||||
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
|
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
|
||||||
import { exists, isFileValid } from './misc'
|
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
|
.concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
|
||||||
.map(m => `(${m})`)
|
.map(m => `(${m})`)
|
||||||
.join('|')
|
.join('|')
|
||||||
function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
|
function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
|
||||||
return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
|
return isFileValid({
|
||||||
|
files,
|
||||||
|
mimeTypeRegex: videoCaptionTypesRegex,
|
||||||
|
field,
|
||||||
|
maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isVTTFileValid (filePath: string) {
|
async function isVTTFileValid (filePath: string) {
|
||||||
|
|
52
server/helpers/custom-validators/video-editor.ts
Normal file
52
server/helpers/custom-validators/video-editor.ts
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
import 'multer'
|
import 'multer'
|
||||||
|
import { UploadFilesForCheck } from 'express'
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
|
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
|
||||||
import { exists, isFileValid } from './misc'
|
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
|
.concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
|
||||||
.map(m => `(${m})`)
|
.map(m => `(${m})`)
|
||||||
.join('|')
|
.join('|')
|
||||||
function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
|
function isVideoImportTorrentFile (files: UploadFilesForCheck) {
|
||||||
return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
|
return isFileValid({
|
||||||
|
files,
|
||||||
|
mimeTypeRegex: videoTorrentImportRegex,
|
||||||
|
field: 'torrentfile',
|
||||||
|
maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
|
||||||
|
optional: true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
VIDEO_RATE_TYPES,
|
VIDEO_RATE_TYPES,
|
||||||
VIDEO_STATES
|
VIDEO_STATES
|
||||||
} from '../../initializers/constants'
|
} 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
|
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)
|
return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoTagsValid (tags: string[]) {
|
function areVideoTagsValid (tags: string[]) {
|
||||||
return tags === null || (
|
return tags === null || (
|
||||||
isArray(tags) &&
|
isArray(tags) &&
|
||||||
validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.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)
|
return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoFileMimeTypeValid (files: UploadFilesForCheck) {
|
function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
|
||||||
return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile')
|
return isFileValid({
|
||||||
|
files,
|
||||||
|
mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
|
||||||
|
field,
|
||||||
|
maxSize: null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
|
const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
|
||||||
|
@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
|
||||||
.join('|')
|
.join('|')
|
||||||
const videoImageTypesRegex = `image/(${videoImageTypes})`
|
const videoImageTypesRegex = `image/(${videoImageTypes})`
|
||||||
|
|
||||||
function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
|
function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
|
||||||
return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true)
|
return isFileValid({
|
||||||
|
files,
|
||||||
|
mimeTypeRegex: videoImageTypesRegex,
|
||||||
|
field,
|
||||||
|
maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
|
||||||
|
optional
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoPrivacyValid (value: number) {
|
function isVideoPrivacyValid (value: number) {
|
||||||
|
@ -144,7 +155,7 @@ export {
|
||||||
isVideoDescriptionValid,
|
isVideoDescriptionValid,
|
||||||
isVideoFileInfoHashValid,
|
isVideoFileInfoHashValid,
|
||||||
isVideoNameValid,
|
isVideoNameValid,
|
||||||
isVideoTagsValid,
|
areVideoTagsValid,
|
||||||
isVideoFPSResolutionValid,
|
isVideoFPSResolutionValid,
|
||||||
isScheduleVideoUpdatePrivacyValid,
|
isScheduleVideoUpdatePrivacyValid,
|
||||||
isVideoOriginallyPublishedAtValid,
|
isVideoOriginallyPublishedAtValid,
|
||||||
|
@ -160,7 +171,7 @@ export {
|
||||||
isVideoPrivacyValid,
|
isVideoPrivacyValid,
|
||||||
isVideoFileResolutionValid,
|
isVideoFileResolutionValid,
|
||||||
isVideoFileSizeValid,
|
isVideoFileSizeValid,
|
||||||
isVideoImage,
|
isVideoImageValid,
|
||||||
isVideoSupportValid,
|
isVideoSupportValid,
|
||||||
isVideoFilterValid
|
isVideoFilterValid
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import express, { RequestHandler } from 'express'
|
import express, { RequestHandler } from 'express'
|
||||||
import multer, { diskStorage } from 'multer'
|
import multer, { diskStorage } from 'multer'
|
||||||
|
import { getLowercaseExtension } from '@shared/core-utils'
|
||||||
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
||||||
import { CONFIG } from '../initializers/config'
|
import { CONFIG } from '../initializers/config'
|
||||||
import { REMOTE_SCHEME } from '../initializers/constants'
|
import { REMOTE_SCHEME } from '../initializers/constants'
|
||||||
import { getLowercaseExtension } from '@shared/core-utils'
|
|
||||||
import { isArray } from './custom-validators/misc'
|
import { isArray } from './custom-validators/misc'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
import { deleteFileAndCatch, generateRandomString } from './utils'
|
import { deleteFileAndCatch, generateRandomString } from './utils'
|
||||||
|
@ -75,29 +75,8 @@ function createReqFiles (
|
||||||
cb(null, destinations[file.fieldname])
|
cb(null, destinations[file.fieldname])
|
||||||
},
|
},
|
||||||
|
|
||||||
filename: async (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
let extension: string
|
return generateReqFilename(file, mimeTypes, cb)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -112,6 +91,24 @@ function createReqFiles (
|
||||||
return multer({ storage }).fields(fields)
|
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) {
|
function isUserAbleToSearchRemoteURI (res: express.Response) {
|
||||||
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
||||||
|
|
||||||
|
@ -128,9 +125,41 @@ function getCountVideos (req: express.Request) {
|
||||||
export {
|
export {
|
||||||
buildNSFWFilter,
|
buildNSFWFilter,
|
||||||
getHostWithPort,
|
getHostWithPort,
|
||||||
|
createAnyReqFiles,
|
||||||
isUserAbleToSearchRemoteURI,
|
isUserAbleToSearchRemoteURI,
|
||||||
badRequest,
|
badRequest,
|
||||||
createReqFiles,
|
createReqFiles,
|
||||||
cleanUpReqFiles,
|
cleanUpReqFiles,
|
||||||
getCountVideos
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -1,781 +0,0 @@
|
||||||
import { Job } from 'bull'
|
|
||||||
import ffmpeg, { FfmpegCommand, FilterSpecification, getAvailableEncoders } from 'fluent-ffmpeg'
|
|
||||||
import { readFile, remove, writeFile } from 'fs-extra'
|
|
||||||
import { dirname, join } from 'path'
|
|
||||||
import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
|
|
||||||
import { pick } from '@shared/core-utils'
|
|
||||||
import {
|
|
||||||
AvailableEncoders,
|
|
||||||
EncoderOptions,
|
|
||||||
EncoderOptionsBuilder,
|
|
||||||
EncoderOptionsBuilderParams,
|
|
||||||
EncoderProfile,
|
|
||||||
VideoResolution
|
|
||||||
} from '../../shared/models/videos'
|
|
||||||
import { CONFIG } from '../initializers/config'
|
|
||||||
import { execPromise, promisify0 } from './core-utils'
|
|
||||||
import { computeFPS, ffprobePromise, getAudioStream, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from './ffprobe-utils'
|
|
||||||
import { processImage } from './image-utils'
|
|
||||||
import { logger, loggerTagsFactory } from './logger'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('ffmpeg')
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* Functions that run transcoding/muxing ffmpeg processes
|
|
||||||
* Mainly called by lib/video-transcoding.ts and lib/live-manager.ts
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Encoder options
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type StreamType = 'audio' | 'video'
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Encoders support
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Detect supported encoders by ffmpeg
|
|
||||||
let supportedEncoders: Map<string, boolean>
|
|
||||||
async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
|
|
||||||
if (supportedEncoders !== undefined) {
|
|
||||||
return supportedEncoders
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
|
|
||||||
const availableFFmpegEncoders = await getAvailableEncodersPromise()
|
|
||||||
|
|
||||||
const searchEncoders = new Set<string>()
|
|
||||||
for (const type of [ 'live', 'vod' ]) {
|
|
||||||
for (const streamType of [ 'audio', 'video' ]) {
|
|
||||||
for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
|
|
||||||
searchEncoders.add(encoder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
supportedEncoders = new Map<string, boolean>()
|
|
||||||
|
|
||||||
for (const searchEncoder of searchEncoders) {
|
|
||||||
supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
|
|
||||||
|
|
||||||
return supportedEncoders
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSupportedEncoders () {
|
|
||||||
supportedEncoders = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Image manipulation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function convertWebPToJPG (path: string, destination: string): Promise<void> {
|
|
||||||
const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
|
|
||||||
.output(destination)
|
|
||||||
|
|
||||||
return runCommand({ command, silent: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function processGIF (
|
|
||||||
path: string,
|
|
||||||
destination: string,
|
|
||||||
newSize: { width: number, height: number }
|
|
||||||
): Promise<void> {
|
|
||||||
const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
|
|
||||||
.fps(20)
|
|
||||||
.size(`${newSize.width}x${newSize.height}`)
|
|
||||||
.output(destination)
|
|
||||||
|
|
||||||
return runCommand({ command })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
|
|
||||||
const pendingImageName = 'pending-' + imageName
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
filename: pendingImageName,
|
|
||||||
count: 1,
|
|
||||||
folder
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingImagePath = join(folder, pendingImageName)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise<string>((res, rej) => {
|
|
||||||
ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
|
|
||||||
.on('error', rej)
|
|
||||||
.on('end', () => res(imageName))
|
|
||||||
.thumbnail(options)
|
|
||||||
})
|
|
||||||
|
|
||||||
const destination = join(folder, imageName)
|
|
||||||
await processImage(pendingImagePath, destination, size)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
|
|
||||||
|
|
||||||
try {
|
|
||||||
await remove(pendingImagePath)
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Transcode meta function
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
|
|
||||||
|
|
||||||
interface BaseTranscodeOptions {
|
|
||||||
type: TranscodeOptionsType
|
|
||||||
|
|
||||||
inputPath: string
|
|
||||||
outputPath: string
|
|
||||||
|
|
||||||
availableEncoders: AvailableEncoders
|
|
||||||
profile: string
|
|
||||||
|
|
||||||
resolution: number
|
|
||||||
|
|
||||||
isPortraitMode?: boolean
|
|
||||||
|
|
||||||
job?: Job
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HLSTranscodeOptions extends BaseTranscodeOptions {
|
|
||||||
type: 'hls'
|
|
||||||
copyCodecs: boolean
|
|
||||||
hlsPlaylist: {
|
|
||||||
videoFilename: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
|
|
||||||
type: 'hls-from-ts'
|
|
||||||
|
|
||||||
isAAC: boolean
|
|
||||||
|
|
||||||
hlsPlaylist: {
|
|
||||||
videoFilename: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuickTranscodeOptions extends BaseTranscodeOptions {
|
|
||||||
type: 'quick-transcode'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoTranscodeOptions extends BaseTranscodeOptions {
|
|
||||||
type: 'video'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
|
|
||||||
type: 'merge-audio'
|
|
||||||
audioPath: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
|
|
||||||
type: 'only-audio'
|
|
||||||
}
|
|
||||||
|
|
||||||
type TranscodeOptions =
|
|
||||||
HLSTranscodeOptions
|
|
||||||
| HLSFromTSTranscodeOptions
|
|
||||||
| VideoTranscodeOptions
|
|
||||||
| MergeAudioTranscodeOptions
|
|
||||||
| OnlyAudioTranscodeOptions
|
|
||||||
| QuickTranscodeOptions
|
|
||||||
|
|
||||||
const builders: {
|
|
||||||
[ type in TranscodeOptionsType ]: (c: FfmpegCommand, o?: TranscodeOptions) => Promise<FfmpegCommand> | FfmpegCommand
|
|
||||||
} = {
|
|
||||||
'quick-transcode': buildQuickTranscodeCommand,
|
|
||||||
'hls': buildHLSVODCommand,
|
|
||||||
'hls-from-ts': buildHLSVODFromTSCommand,
|
|
||||||
'merge-audio': buildAudioMergeCommand,
|
|
||||||
'only-audio': buildOnlyAudioCommand,
|
|
||||||
'video': buildx264VODCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
async function transcode (options: TranscodeOptions) {
|
|
||||||
logger.debug('Will run transcode.', { options, ...lTags() })
|
|
||||||
|
|
||||||
let command = getFFmpeg(options.inputPath, 'vod')
|
|
||||||
.output(options.outputPath)
|
|
||||||
|
|
||||||
command = await builders[options.type](command, options)
|
|
||||||
|
|
||||||
await runCommand({ command, job: options.job })
|
|
||||||
|
|
||||||
await fixHLSPlaylistIfNeeded(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Live muxing/transcoding functions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function getLiveTranscodingCommand (options: {
|
|
||||||
inputUrl: string
|
|
||||||
|
|
||||||
outPath: string
|
|
||||||
masterPlaylistName: string
|
|
||||||
|
|
||||||
resolutions: number[]
|
|
||||||
|
|
||||||
// Input information
|
|
||||||
fps: number
|
|
||||||
bitrate: number
|
|
||||||
ratio: number
|
|
||||||
|
|
||||||
availableEncoders: AvailableEncoders
|
|
||||||
profile: string
|
|
||||||
}) {
|
|
||||||
const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
|
|
||||||
|
|
||||||
const command = getFFmpeg(inputUrl, 'live')
|
|
||||||
|
|
||||||
const varStreamMap: string[] = []
|
|
||||||
|
|
||||||
const complexFilter: FilterSpecification[] = [
|
|
||||||
{
|
|
||||||
inputs: '[v:0]',
|
|
||||||
filter: 'split',
|
|
||||||
options: resolutions.length,
|
|
||||||
outputs: resolutions.map(r => `vtemp${r}`)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
command.outputOption('-sc_threshold 0')
|
|
||||||
|
|
||||||
addDefaultEncoderGlobalParams({ command })
|
|
||||||
|
|
||||||
for (let i = 0; i < resolutions.length; i++) {
|
|
||||||
const resolution = resolutions[i]
|
|
||||||
const resolutionFPS = computeFPS(fps, resolution)
|
|
||||||
|
|
||||||
const baseEncoderBuilderParams = {
|
|
||||||
input: inputUrl,
|
|
||||||
|
|
||||||
availableEncoders,
|
|
||||||
profile,
|
|
||||||
|
|
||||||
inputBitrate: bitrate,
|
|
||||||
inputRatio: ratio,
|
|
||||||
|
|
||||||
resolution,
|
|
||||||
fps: resolutionFPS,
|
|
||||||
|
|
||||||
streamNum: i,
|
|
||||||
videoType: 'live' as 'live'
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const streamType: StreamType = 'video'
|
|
||||||
const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
|
||||||
if (!builderResult) {
|
|
||||||
throw new Error('No available live video encoder found')
|
|
||||||
}
|
|
||||||
|
|
||||||
command.outputOption(`-map [vout${resolution}]`)
|
|
||||||
|
|
||||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
|
|
||||||
{ builderResult, fps: resolutionFPS, resolution, ...lTags() }
|
|
||||||
)
|
|
||||||
|
|
||||||
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
|
|
||||||
applyEncoderOptions(command, builderResult.result)
|
|
||||||
|
|
||||||
complexFilter.push({
|
|
||||||
inputs: `vtemp${resolution}`,
|
|
||||||
filter: getScaleFilter(builderResult.result),
|
|
||||||
options: `w=-2:h=${resolution}`,
|
|
||||||
outputs: `vout${resolution}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const streamType: StreamType = 'audio'
|
|
||||||
const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
|
||||||
if (!builderResult) {
|
|
||||||
throw new Error('No available live audio encoder found')
|
|
||||||
}
|
|
||||||
|
|
||||||
command.outputOption('-map a:0')
|
|
||||||
|
|
||||||
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
|
|
||||||
{ builderResult, fps: resolutionFPS, resolution, ...lTags() }
|
|
||||||
)
|
|
||||||
|
|
||||||
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
|
|
||||||
applyEncoderOptions(command, builderResult.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
varStreamMap.push(`v:${i},a:${i}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
command.complexFilter(complexFilter)
|
|
||||||
|
|
||||||
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
|
|
||||||
|
|
||||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
|
|
||||||
const command = getFFmpeg(inputUrl, 'live')
|
|
||||||
|
|
||||||
command.outputOption('-c:v copy')
|
|
||||||
command.outputOption('-c:a copy')
|
|
||||||
command.outputOption('-map 0:a?')
|
|
||||||
command.outputOption('-map 0:v?')
|
|
||||||
|
|
||||||
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStreamSuffix (base: string, streamNum?: number) {
|
|
||||||
if (streamNum !== undefined) {
|
|
||||||
return `${base}:${streamNum}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Default options
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function addDefaultEncoderGlobalParams (options: {
|
|
||||||
command: FfmpegCommand
|
|
||||||
}) {
|
|
||||||
const { command } = options
|
|
||||||
|
|
||||||
// avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
|
|
||||||
command.outputOption('-max_muxing_queue_size 1024')
|
|
||||||
// strip all metadata
|
|
||||||
.outputOption('-map_metadata -1')
|
|
||||||
// allows import of source material with incompatible pixel formats (e.g. MJPEG video)
|
|
||||||
.outputOption('-pix_fmt yuv420p')
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDefaultEncoderParams (options: {
|
|
||||||
command: FfmpegCommand
|
|
||||||
encoder: 'libx264' | string
|
|
||||||
streamNum?: number
|
|
||||||
fps?: number
|
|
||||||
}) {
|
|
||||||
const { command, encoder, fps, streamNum } = options
|
|
||||||
|
|
||||||
if (encoder === 'libx264') {
|
|
||||||
// 3.1 is the minimal resource allocation for our highest supported resolution
|
|
||||||
command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
|
|
||||||
|
|
||||||
if (fps) {
|
|
||||||
// Keyframe interval of 2 seconds for faster seeking and resolution switching.
|
|
||||||
// https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
|
|
||||||
// https://superuser.com/a/908325
|
|
||||||
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
|
|
||||||
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
|
|
||||||
command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
|
|
||||||
command.outputOption('-hls_flags delete_segments+independent_segments')
|
|
||||||
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
|
|
||||||
command.outputOption('-master_pl_name ' + masterPlaylistName)
|
|
||||||
command.outputOption(`-f hls`)
|
|
||||||
|
|
||||||
command.output(join(outPath, '%v.m3u8'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Transcode VOD command builders
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function buildx264VODCommand (command: FfmpegCommand, options: TranscodeOptions) {
|
|
||||||
let fps = await getVideoFileFPS(options.inputPath)
|
|
||||||
fps = computeFPS(fps, options.resolution)
|
|
||||||
|
|
||||||
let scaleFilterValue: string
|
|
||||||
|
|
||||||
if (options.resolution !== undefined) {
|
|
||||||
scaleFilterValue = options.isPortraitMode === true
|
|
||||||
? `w=${options.resolution}:h=-2`
|
|
||||||
: `w=-2:h=${options.resolution}`
|
|
||||||
}
|
|
||||||
|
|
||||||
command = await presetVideo({ command, input: options.inputPath, transcodeOptions: options, fps, scaleFilterValue })
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
|
|
||||||
command = command.loop(undefined)
|
|
||||||
|
|
||||||
const scaleFilterValue = getScaleCleanerValue()
|
|
||||||
command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
|
|
||||||
|
|
||||||
command.outputOption('-preset:v veryfast')
|
|
||||||
|
|
||||||
command = command.input(options.audioPath)
|
|
||||||
.outputOption('-tune stillimage')
|
|
||||||
.outputOption('-shortest')
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
|
|
||||||
command = presetOnlyAudio(command)
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildQuickTranscodeCommand (command: FfmpegCommand) {
|
|
||||||
command = presetCopy(command)
|
|
||||||
|
|
||||||
command = command.outputOption('-map_metadata -1') // strip all metadata
|
|
||||||
.outputOption('-movflags faststart')
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
|
|
||||||
return command.outputOption('-hls_time 4')
|
|
||||||
.outputOption('-hls_list_size 0')
|
|
||||||
.outputOption('-hls_playlist_type vod')
|
|
||||||
.outputOption('-hls_segment_filename ' + outputPath)
|
|
||||||
.outputOption('-hls_segment_type fmp4')
|
|
||||||
.outputOption('-f hls')
|
|
||||||
.outputOption('-hls_flags single_file')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
|
|
||||||
const videoPath = getHLSVideoPath(options)
|
|
||||||
|
|
||||||
if (options.copyCodecs) command = presetCopy(command)
|
|
||||||
else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
|
|
||||||
else command = await buildx264VODCommand(command, options)
|
|
||||||
|
|
||||||
addCommonHLSVODCommandOptions(command, videoPath)
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
|
|
||||||
const videoPath = getHLSVideoPath(options)
|
|
||||||
|
|
||||||
command.outputOption('-c copy')
|
|
||||||
|
|
||||||
if (options.isAAC) {
|
|
||||||
// Required for example when copying an AAC stream from an MPEG-TS
|
|
||||||
// Since it's a bitstream filter, we don't need to reencode the audio
|
|
||||||
command.outputOption('-bsf:a aac_adtstoasc')
|
|
||||||
}
|
|
||||||
|
|
||||||
addCommonHLSVODCommandOptions(command, videoPath)
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
|
|
||||||
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
|
|
||||||
|
|
||||||
const fileContent = await readFile(options.outputPath)
|
|
||||||
|
|
||||||
const videoFileName = options.hlsPlaylist.videoFilename
|
|
||||||
const videoFilePath = getHLSVideoPath(options)
|
|
||||||
|
|
||||||
// Fix wrong mapping with some ffmpeg versions
|
|
||||||
const newContent = fileContent.toString()
|
|
||||||
.replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
|
|
||||||
|
|
||||||
await writeFile(options.outputPath, newContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
|
|
||||||
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Transcoding presets
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Run encoder builder depending on available encoders
|
|
||||||
// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
|
|
||||||
// If the default one does not exist, check the next encoder
|
|
||||||
async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
|
|
||||||
streamType: 'video' | 'audio'
|
|
||||||
input: string
|
|
||||||
|
|
||||||
availableEncoders: AvailableEncoders
|
|
||||||
profile: string
|
|
||||||
|
|
||||||
videoType: 'vod' | 'live'
|
|
||||||
}) {
|
|
||||||
const { availableEncoders, profile, streamType, videoType } = options
|
|
||||||
|
|
||||||
const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
|
|
||||||
const encoders = availableEncoders.available[videoType]
|
|
||||||
|
|
||||||
for (const encoder of encodersToTry) {
|
|
||||||
if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
|
|
||||||
logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!encoders[encoder]) {
|
|
||||||
logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// An object containing available profiles for this encoder
|
|
||||||
const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
|
|
||||||
let builder = builderProfiles[profile]
|
|
||||||
|
|
||||||
if (!builder) {
|
|
||||||
logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
|
|
||||||
builder = builderProfiles.default
|
|
||||||
|
|
||||||
if (!builder) {
|
|
||||||
logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await builder(pick(options, [ 'input', 'resolution', 'inputBitrate', 'fps', 'inputRatio', 'streamNum' ]))
|
|
||||||
|
|
||||||
return {
|
|
||||||
result,
|
|
||||||
|
|
||||||
// If we don't have output options, then copy the input stream
|
|
||||||
encoder: result.copy === true
|
|
||||||
? 'copy'
|
|
||||||
: encoder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function presetVideo (options: {
|
|
||||||
command: FfmpegCommand
|
|
||||||
input: string
|
|
||||||
transcodeOptions: TranscodeOptions
|
|
||||||
fps?: number
|
|
||||||
scaleFilterValue?: string
|
|
||||||
}) {
|
|
||||||
const { command, input, transcodeOptions, fps, scaleFilterValue } = options
|
|
||||||
|
|
||||||
let localCommand = command
|
|
||||||
.format('mp4')
|
|
||||||
.outputOption('-movflags faststart')
|
|
||||||
|
|
||||||
addDefaultEncoderGlobalParams({ command })
|
|
||||||
|
|
||||||
const probe = await ffprobePromise(input)
|
|
||||||
|
|
||||||
// Audio encoder
|
|
||||||
const parsedAudio = await getAudioStream(input, probe)
|
|
||||||
const bitrate = await getVideoFileBitrate(input, probe)
|
|
||||||
const { ratio } = await getVideoFileResolution(input, probe)
|
|
||||||
|
|
||||||
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
|
||||||
|
|
||||||
if (!parsedAudio.audioStream) {
|
|
||||||
localCommand = localCommand.noAudio()
|
|
||||||
streamsToProcess = [ 'video' ]
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const streamType of streamsToProcess) {
|
|
||||||
const { profile, resolution, availableEncoders } = transcodeOptions
|
|
||||||
|
|
||||||
const builderResult = await getEncoderBuilderResult({
|
|
||||||
streamType,
|
|
||||||
input,
|
|
||||||
resolution,
|
|
||||||
availableEncoders,
|
|
||||||
profile,
|
|
||||||
fps,
|
|
||||||
inputBitrate: bitrate,
|
|
||||||
inputRatio: ratio,
|
|
||||||
videoType: 'vod' as 'vod'
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!builderResult) {
|
|
||||||
throw new Error('No available encoder found for stream ' + streamType)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
|
|
||||||
builderResult.encoder, streamType, input, profile,
|
|
||||||
{ builderResult, resolution, fps, ...lTags() }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (streamType === 'video') {
|
|
||||||
localCommand.videoCodec(builderResult.encoder)
|
|
||||||
|
|
||||||
if (scaleFilterValue) {
|
|
||||||
localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
|
|
||||||
}
|
|
||||||
} else if (streamType === 'audio') {
|
|
||||||
localCommand.audioCodec(builderResult.encoder)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyEncoderOptions(localCommand, builderResult.result)
|
|
||||||
addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
|
|
||||||
}
|
|
||||||
|
|
||||||
return localCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetCopy (command: FfmpegCommand): FfmpegCommand {
|
|
||||||
return command
|
|
||||||
.format('mp4')
|
|
||||||
.videoCodec('copy')
|
|
||||||
.audioCodec('copy')
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
|
|
||||||
return command
|
|
||||||
.format('mp4')
|
|
||||||
.audioCodec('copy')
|
|
||||||
.noVideo()
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
|
|
||||||
return command
|
|
||||||
.inputOptions(options.inputOptions ?? [])
|
|
||||||
.outputOptions(options.outputOptions ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScaleFilter (options: EncoderOptions): string {
|
|
||||||
if (options.scaleFilter) return options.scaleFilter.name
|
|
||||||
|
|
||||||
return 'scale'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Utils
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function getFFmpeg (input: string, type: 'live' | 'vod') {
|
|
||||||
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
|
|
||||||
const command = ffmpeg(input, {
|
|
||||||
niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
|
|
||||||
cwd: CONFIG.STORAGE.TMP_DIR
|
|
||||||
})
|
|
||||||
|
|
||||||
const threads = type === 'live'
|
|
||||||
? CONFIG.LIVE.TRANSCODING.THREADS
|
|
||||||
: CONFIG.TRANSCODING.THREADS
|
|
||||||
|
|
||||||
if (threads > 0) {
|
|
||||||
// If we don't set any threads ffmpeg will chose automatically
|
|
||||||
command.outputOption('-threads ' + threads)
|
|
||||||
}
|
|
||||||
|
|
||||||
return command
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFFmpegVersion () {
|
|
||||||
return new Promise<string>((res, rej) => {
|
|
||||||
(ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
|
|
||||||
if (err) return rej(err)
|
|
||||||
if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
|
|
||||||
|
|
||||||
return execPromise(`${ffmpegPath} -version`)
|
|
||||||
.then(stdout => {
|
|
||||||
const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
|
|
||||||
if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
|
|
||||||
|
|
||||||
// Fix ffmpeg version that does not include patch version (4.4 for example)
|
|
||||||
let version = parsed[1]
|
|
||||||
if (version.match(/^\d+\.\d+$/)) {
|
|
||||||
version += '.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
return res(version)
|
|
||||||
})
|
|
||||||
.catch(err => rej(err))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCommand (options: {
|
|
||||||
command: FfmpegCommand
|
|
||||||
silent?: boolean // false
|
|
||||||
job?: Job
|
|
||||||
}) {
|
|
||||||
const { command, silent = false, job } = options
|
|
||||||
|
|
||||||
return new Promise<void>((res, rej) => {
|
|
||||||
let shellCommand: string
|
|
||||||
|
|
||||||
command.on('start', cmdline => { shellCommand = cmdline })
|
|
||||||
|
|
||||||
command.on('error', (err, stdout, stderr) => {
|
|
||||||
if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
|
|
||||||
|
|
||||||
rej(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
command.on('end', (stdout, stderr) => {
|
|
||||||
logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
|
|
||||||
|
|
||||||
res()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (job) {
|
|
||||||
command.on('progress', progress => {
|
|
||||||
if (!progress.percent) return
|
|
||||||
|
|
||||||
job.progress(Math.round(progress.percent))
|
|
||||||
.catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
command.run()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid "height not divisible by 2" error
|
|
||||||
function getScaleCleanerValue () {
|
|
||||||
return 'trunc(iw/2)*2:trunc(ih/2)*2'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export {
|
|
||||||
getLiveTranscodingCommand,
|
|
||||||
getLiveMuxingCommand,
|
|
||||||
buildStreamSuffix,
|
|
||||||
convertWebPToJPG,
|
|
||||||
processGIF,
|
|
||||||
generateImageFromVideoFile,
|
|
||||||
TranscodeOptions,
|
|
||||||
TranscodeOptionsType,
|
|
||||||
transcode,
|
|
||||||
runCommand,
|
|
||||||
getFFmpegVersion,
|
|
||||||
|
|
||||||
resetSupportedEncoders,
|
|
||||||
|
|
||||||
// builders
|
|
||||||
buildx264VODCommand
|
|
||||||
}
|
|
114
server/helpers/ffmpeg/ffmpeg-commons.ts
Normal file
114
server/helpers/ffmpeg/ffmpeg-commons.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import { Job } from 'bull'
|
||||||
|
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
|
import { execPromise } from '@server/helpers/core-utils'
|
||||||
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { FFMPEG_NICE } from '@server/initializers/constants'
|
||||||
|
import { EncoderOptions } from '@shared/models'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('ffmpeg')
|
||||||
|
|
||||||
|
type StreamType = 'audio' | 'video'
|
||||||
|
|
||||||
|
function getFFmpeg (input: string, type: 'live' | 'vod') {
|
||||||
|
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
|
||||||
|
const command = ffmpeg(input, {
|
||||||
|
niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
|
||||||
|
cwd: CONFIG.STORAGE.TMP_DIR
|
||||||
|
})
|
||||||
|
|
||||||
|
const threads = type === 'live'
|
||||||
|
? CONFIG.LIVE.TRANSCODING.THREADS
|
||||||
|
: CONFIG.TRANSCODING.THREADS
|
||||||
|
|
||||||
|
if (threads > 0) {
|
||||||
|
// If we don't set any threads ffmpeg will chose automatically
|
||||||
|
command.outputOption('-threads ' + threads)
|
||||||
|
}
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFFmpegVersion () {
|
||||||
|
return new Promise<string>((res, rej) => {
|
||||||
|
(ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
|
||||||
|
if (err) return rej(err)
|
||||||
|
if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
|
||||||
|
|
||||||
|
return execPromise(`${ffmpegPath} -version`)
|
||||||
|
.then(stdout => {
|
||||||
|
const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
|
||||||
|
if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
|
||||||
|
|
||||||
|
// Fix ffmpeg version that does not include patch version (4.4 for example)
|
||||||
|
let version = parsed[1]
|
||||||
|
if (version.match(/^\d+\.\d+$/)) {
|
||||||
|
version += '.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
return res(version)
|
||||||
|
})
|
||||||
|
.catch(err => rej(err))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand (options: {
|
||||||
|
command: FfmpegCommand
|
||||||
|
silent?: boolean // false by default
|
||||||
|
job?: Job
|
||||||
|
}) {
|
||||||
|
const { command, silent = false, job } = options
|
||||||
|
|
||||||
|
return new Promise<void>((res, rej) => {
|
||||||
|
let shellCommand: string
|
||||||
|
|
||||||
|
command.on('start', cmdline => { shellCommand = cmdline })
|
||||||
|
|
||||||
|
command.on('error', (err, stdout, stderr) => {
|
||||||
|
if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
|
||||||
|
|
||||||
|
rej(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
command.on('end', (stdout, stderr) => {
|
||||||
|
logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
|
||||||
|
|
||||||
|
res()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
command.on('progress', progress => {
|
||||||
|
if (!progress.percent) return
|
||||||
|
|
||||||
|
job.progress(Math.round(progress.percent))
|
||||||
|
.catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
command.run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStreamSuffix (base: string, streamNum?: number) {
|
||||||
|
if (streamNum !== undefined) {
|
||||||
|
return `${base}:${streamNum}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScaleFilter (options: EncoderOptions): string {
|
||||||
|
if (options.scaleFilter) return options.scaleFilter.name
|
||||||
|
|
||||||
|
return 'scale'
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getFFmpeg,
|
||||||
|
getFFmpegVersion,
|
||||||
|
runCommand,
|
||||||
|
StreamType,
|
||||||
|
buildStreamSuffix,
|
||||||
|
getScaleFilter
|
||||||
|
}
|
242
server/helpers/ffmpeg/ffmpeg-edition.ts
Normal file
242
server/helpers/ffmpeg/ffmpeg-edition.ts
Normal file
|
@ -0,0 +1,242 @@
|
||||||
|
import { FilterSpecification } from 'fluent-ffmpeg'
|
||||||
|
import { VIDEO_FILTERS } from '@server/initializers/constants'
|
||||||
|
import { AvailableEncoders } from '@shared/models'
|
||||||
|
import { logger, loggerTagsFactory } from '../logger'
|
||||||
|
import { getFFmpeg, runCommand } from './ffmpeg-commons'
|
||||||
|
import { presetCopy, presetVOD } from './ffmpeg-presets'
|
||||||
|
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('ffmpeg')
|
||||||
|
|
||||||
|
async function cutVideo (options: {
|
||||||
|
inputPath: string
|
||||||
|
outputPath: string
|
||||||
|
start?: number
|
||||||
|
end?: number
|
||||||
|
}) {
|
||||||
|
const { inputPath, outputPath } = options
|
||||||
|
|
||||||
|
logger.debug('Will cut the video.', { options, ...lTags() })
|
||||||
|
|
||||||
|
let command = getFFmpeg(inputPath, 'vod')
|
||||||
|
.output(outputPath)
|
||||||
|
|
||||||
|
command = presetCopy(command)
|
||||||
|
|
||||||
|
if (options.start) command.inputOption('-ss ' + options.start)
|
||||||
|
|
||||||
|
if (options.end) {
|
||||||
|
const endSeeking = options.end - (options.start || 0)
|
||||||
|
|
||||||
|
command.outputOption('-to ' + endSeeking)
|
||||||
|
}
|
||||||
|
|
||||||
|
await runCommand({ command })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWatermark (options: {
|
||||||
|
inputPath: string
|
||||||
|
watermarkPath: string
|
||||||
|
outputPath: string
|
||||||
|
|
||||||
|
availableEncoders: AvailableEncoders
|
||||||
|
profile: string
|
||||||
|
}) {
|
||||||
|
const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options
|
||||||
|
|
||||||
|
logger.debug('Will add watermark to the video.', { options, ...lTags() })
|
||||||
|
|
||||||
|
const videoProbe = await ffprobePromise(inputPath)
|
||||||
|
const fps = await getVideoStreamFPS(inputPath, videoProbe)
|
||||||
|
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
|
||||||
|
|
||||||
|
let command = getFFmpeg(inputPath, 'vod')
|
||||||
|
.output(outputPath)
|
||||||
|
command.input(watermarkPath)
|
||||||
|
|
||||||
|
command = await presetVOD({
|
||||||
|
command,
|
||||||
|
input: inputPath,
|
||||||
|
availableEncoders,
|
||||||
|
profile,
|
||||||
|
resolution,
|
||||||
|
fps,
|
||||||
|
canCopyAudio: true,
|
||||||
|
canCopyVideo: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const complexFilter: FilterSpecification[] = [
|
||||||
|
// Scale watermark
|
||||||
|
{
|
||||||
|
inputs: [ '[1]', '[0]' ],
|
||||||
|
filter: 'scale2ref',
|
||||||
|
options: {
|
||||||
|
w: 'oh*mdar',
|
||||||
|
h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}`
|
||||||
|
},
|
||||||
|
outputs: [ '[watermark]', '[video]' ]
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
inputs: [ '[video]', '[watermark]' ],
|
||||||
|
filter: 'overlay',
|
||||||
|
options: {
|
||||||
|
x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`,
|
||||||
|
y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
command.complexFilter(complexFilter)
|
||||||
|
|
||||||
|
await runCommand({ command })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addIntroOutro (options: {
|
||||||
|
inputPath: string
|
||||||
|
introOutroPath: string
|
||||||
|
outputPath: string
|
||||||
|
type: 'intro' | 'outro'
|
||||||
|
|
||||||
|
availableEncoders: AvailableEncoders
|
||||||
|
profile: string
|
||||||
|
}) {
|
||||||
|
const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options
|
||||||
|
|
||||||
|
logger.debug('Will add intro/outro to the video.', { options, ...lTags() })
|
||||||
|
|
||||||
|
const mainProbe = await ffprobePromise(inputPath)
|
||||||
|
const fps = await getVideoStreamFPS(inputPath, mainProbe)
|
||||||
|
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
|
||||||
|
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
|
||||||
|
|
||||||
|
const introOutroProbe = await ffprobePromise(introOutroPath)
|
||||||
|
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
|
||||||
|
|
||||||
|
let command = getFFmpeg(inputPath, 'vod')
|
||||||
|
.output(outputPath)
|
||||||
|
|
||||||
|
command.input(introOutroPath)
|
||||||
|
|
||||||
|
if (!introOutroHasAudio && mainHasAudio) {
|
||||||
|
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
|
||||||
|
|
||||||
|
command.input('anullsrc')
|
||||||
|
command.withInputFormat('lavfi')
|
||||||
|
command.withInputOption('-t ' + duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
command = await presetVOD({
|
||||||
|
command,
|
||||||
|
input: inputPath,
|
||||||
|
availableEncoders,
|
||||||
|
profile,
|
||||||
|
resolution,
|
||||||
|
fps,
|
||||||
|
canCopyAudio: false,
|
||||||
|
canCopyVideo: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add black background to correctly scale intro/outro with padding
|
||||||
|
const complexFilter: FilterSpecification[] = [
|
||||||
|
{
|
||||||
|
inputs: [ '1', '0' ],
|
||||||
|
filter: 'scale2ref',
|
||||||
|
options: {
|
||||||
|
w: 'iw',
|
||||||
|
h: `ih`
|
||||||
|
},
|
||||||
|
outputs: [ 'intro-outro', 'main' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [ 'intro-outro', 'main' ],
|
||||||
|
filter: 'scale2ref',
|
||||||
|
options: {
|
||||||
|
w: 'iw',
|
||||||
|
h: `ih`
|
||||||
|
},
|
||||||
|
outputs: [ 'to-scale', 'main' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: 'to-scale',
|
||||||
|
filter: 'drawbox',
|
||||||
|
options: {
|
||||||
|
t: 'fill'
|
||||||
|
},
|
||||||
|
outputs: [ 'to-scale-bg' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [ '1', 'to-scale-bg' ],
|
||||||
|
filter: 'scale2ref',
|
||||||
|
options: {
|
||||||
|
w: 'iw',
|
||||||
|
h: 'ih',
|
||||||
|
force_original_aspect_ratio: 'decrease',
|
||||||
|
flags: 'spline'
|
||||||
|
},
|
||||||
|
outputs: [ 'to-scale', 'to-scale-bg' ]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputs: [ 'to-scale-bg', 'to-scale' ],
|
||||||
|
filter: 'overlay',
|
||||||
|
options: {
|
||||||
|
x: '(main_w - overlay_w)/2',
|
||||||
|
y: '(main_h - overlay_h)/2'
|
||||||
|
},
|
||||||
|
outputs: 'intro-outro-resized'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const concatFilter = {
|
||||||
|
inputs: [],
|
||||||
|
filter: 'concat',
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
v: 1,
|
||||||
|
unsafe: 1
|
||||||
|
},
|
||||||
|
outputs: [ 'v' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
const introOutroFilterInputs = [ 'intro-outro-resized' ]
|
||||||
|
const mainFilterInputs = [ 'main' ]
|
||||||
|
|
||||||
|
if (mainHasAudio) {
|
||||||
|
mainFilterInputs.push('0:a')
|
||||||
|
|
||||||
|
if (introOutroHasAudio) {
|
||||||
|
introOutroFilterInputs.push('1:a')
|
||||||
|
} else {
|
||||||
|
// Silent input
|
||||||
|
introOutroFilterInputs.push('2:a')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'intro') {
|
||||||
|
concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
|
||||||
|
} else {
|
||||||
|
concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainHasAudio) {
|
||||||
|
concatFilter.options['a'] = 1
|
||||||
|
concatFilter.outputs.push('a')
|
||||||
|
|
||||||
|
command.outputOption('-map [a]')
|
||||||
|
}
|
||||||
|
|
||||||
|
command.outputOption('-map [v]')
|
||||||
|
|
||||||
|
complexFilter.push(concatFilter)
|
||||||
|
command.complexFilter(complexFilter)
|
||||||
|
|
||||||
|
await runCommand({ command })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
cutVideo,
|
||||||
|
addIntroOutro,
|
||||||
|
addWatermark
|
||||||
|
}
|
116
server/helpers/ffmpeg/ffmpeg-encoders.ts
Normal file
116
server/helpers/ffmpeg/ffmpeg-encoders.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import { getAvailableEncoders } from 'fluent-ffmpeg'
|
||||||
|
import { pick } from '@shared/core-utils'
|
||||||
|
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
|
||||||
|
import { promisify0 } from '../core-utils'
|
||||||
|
import { logger, loggerTagsFactory } from '../logger'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('ffmpeg')
|
||||||
|
|
||||||
|
// Detect supported encoders by ffmpeg
|
||||||
|
let supportedEncoders: Map<string, boolean>
|
||||||
|
async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
|
||||||
|
if (supportedEncoders !== undefined) {
|
||||||
|
return supportedEncoders
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
|
||||||
|
const availableFFmpegEncoders = await getAvailableEncodersPromise()
|
||||||
|
|
||||||
|
const searchEncoders = new Set<string>()
|
||||||
|
for (const type of [ 'live', 'vod' ]) {
|
||||||
|
for (const streamType of [ 'audio', 'video' ]) {
|
||||||
|
for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
|
||||||
|
searchEncoders.add(encoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
supportedEncoders = new Map<string, boolean>()
|
||||||
|
|
||||||
|
for (const searchEncoder of searchEncoders) {
|
||||||
|
supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
|
||||||
|
|
||||||
|
return supportedEncoders
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSupportedEncoders () {
|
||||||
|
supportedEncoders = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run encoder builder depending on available encoders
|
||||||
|
// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
|
||||||
|
// If the default one does not exist, check the next encoder
|
||||||
|
async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
|
||||||
|
streamType: 'video' | 'audio'
|
||||||
|
input: string
|
||||||
|
|
||||||
|
availableEncoders: AvailableEncoders
|
||||||
|
profile: string
|
||||||
|
|
||||||
|
videoType: 'vod' | 'live'
|
||||||
|
}) {
|
||||||
|
const { availableEncoders, profile, streamType, videoType } = options
|
||||||
|
|
||||||
|
const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
|
||||||
|
const encoders = availableEncoders.available[videoType]
|
||||||
|
|
||||||
|
for (const encoder of encodersToTry) {
|
||||||
|
if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
|
||||||
|
logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!encoders[encoder]) {
|
||||||
|
logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// An object containing available profiles for this encoder
|
||||||
|
const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
|
||||||
|
let builder = builderProfiles[profile]
|
||||||
|
|
||||||
|
if (!builder) {
|
||||||
|
logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
|
||||||
|
builder = builderProfiles.default
|
||||||
|
|
||||||
|
if (!builder) {
|
||||||
|
logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await builder(
|
||||||
|
pick(options, [
|
||||||
|
'input',
|
||||||
|
'canCopyAudio',
|
||||||
|
'canCopyVideo',
|
||||||
|
'resolution',
|
||||||
|
'inputBitrate',
|
||||||
|
'fps',
|
||||||
|
'inputRatio',
|
||||||
|
'streamNum'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
|
||||||
|
// If we don't have output options, then copy the input stream
|
||||||
|
encoder: result.copy === true
|
||||||
|
? 'copy'
|
||||||
|
: encoder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
checkFFmpegEncoders,
|
||||||
|
resetSupportedEncoders,
|
||||||
|
|
||||||
|
getEncoderBuilderResult
|
||||||
|
}
|
46
server/helpers/ffmpeg/ffmpeg-images.ts
Normal file
46
server/helpers/ffmpeg/ffmpeg-images.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import ffmpeg from 'fluent-ffmpeg'
|
||||||
|
import { FFMPEG_NICE } from '@server/initializers/constants'
|
||||||
|
import { runCommand } from './ffmpeg-commons'
|
||||||
|
|
||||||
|
function convertWebPToJPG (path: string, destination: string): Promise<void> {
|
||||||
|
const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
|
||||||
|
.output(destination)
|
||||||
|
|
||||||
|
return runCommand({ command, silent: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function processGIF (
|
||||||
|
path: string,
|
||||||
|
destination: string,
|
||||||
|
newSize: { width: number, height: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
|
||||||
|
.fps(20)
|
||||||
|
.size(`${newSize.width}x${newSize.height}`)
|
||||||
|
.output(destination)
|
||||||
|
|
||||||
|
return runCommand({ command })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) {
|
||||||
|
const pendingImageName = 'pending-' + imageName
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
filename: pendingImageName,
|
||||||
|
count: 1,
|
||||||
|
folder
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<string>((res, rej) => {
|
||||||
|
ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
|
||||||
|
.on('error', rej)
|
||||||
|
.on('end', () => res(imageName))
|
||||||
|
.thumbnail(options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
convertWebPToJPG,
|
||||||
|
processGIF,
|
||||||
|
generateThumbnailFromVideo
|
||||||
|
}
|
161
server/helpers/ffmpeg/ffmpeg-live.ts
Normal file
161
server/helpers/ffmpeg/ffmpeg-live.ts
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { VIDEO_LIVE } from '@server/initializers/constants'
|
||||||
|
import { AvailableEncoders } from '@shared/models'
|
||||||
|
import { logger, loggerTagsFactory } from '../logger'
|
||||||
|
import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
|
||||||
|
import { getEncoderBuilderResult } from './ffmpeg-encoders'
|
||||||
|
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
|
||||||
|
import { computeFPS } from './ffprobe-utils'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('ffmpeg')
|
||||||
|
|
||||||
|
async function getLiveTranscodingCommand (options: {
|
||||||
|
inputUrl: string
|
||||||
|
|
||||||
|
outPath: string
|
||||||
|
masterPlaylistName: string
|
||||||
|
|
||||||
|
resolutions: number[]
|
||||||
|
|
||||||
|
// Input information
|
||||||
|
fps: number
|
||||||
|
bitrate: number
|
||||||
|
ratio: number
|
||||||
|
|
||||||
|
availableEncoders: AvailableEncoders
|
||||||
|
profile: string
|
||||||
|
}) {
|
||||||
|
const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
|
||||||
|
|
||||||
|
const command = getFFmpeg(inputUrl, 'live')
|
||||||
|
|
||||||
|
const varStreamMap: string[] = []
|
||||||
|
|
||||||
|
const complexFilter: FilterSpecification[] = [
|
||||||
|
{
|
||||||
|
inputs: '[v:0]',
|
||||||
|
filter: 'split',
|
||||||
|
options: resolutions.length,
|
||||||
|
outputs: resolutions.map(r => `vtemp${r}`)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
command.outputOption('-sc_threshold 0')
|
||||||
|
|
||||||
|
addDefaultEncoderGlobalParams(command)
|
||||||
|
|
||||||
|
for (let i = 0; i < resolutions.length; i++) {
|
||||||
|
const resolution = resolutions[i]
|
||||||
|
const resolutionFPS = computeFPS(fps, resolution)
|
||||||
|
|
||||||
|
const baseEncoderBuilderParams = {
|
||||||
|
input: inputUrl,
|
||||||
|
|
||||||
|
availableEncoders,
|
||||||
|
profile,
|
||||||
|
|
||||||
|
canCopyAudio: true,
|
||||||
|
canCopyVideo: true,
|
||||||
|
|
||||||
|
inputBitrate: bitrate,
|
||||||
|
inputRatio: ratio,
|
||||||
|
|
||||||
|
resolution,
|
||||||
|
fps: resolutionFPS,
|
||||||
|
|
||||||
|
streamNum: i,
|
||||||
|
videoType: 'live' as 'live'
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const streamType: StreamType = 'video'
|
||||||
|
const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||||
|
if (!builderResult) {
|
||||||
|
throw new Error('No available live video encoder found')
|
||||||
|
}
|
||||||
|
|
||||||
|
command.outputOption(`-map [vout${resolution}]`)
|
||||||
|
|
||||||
|
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
|
||||||
|
{ builderResult, fps: resolutionFPS, resolution, ...lTags() }
|
||||||
|
)
|
||||||
|
|
||||||
|
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
|
||||||
|
applyEncoderOptions(command, builderResult.result)
|
||||||
|
|
||||||
|
complexFilter.push({
|
||||||
|
inputs: `vtemp${resolution}`,
|
||||||
|
filter: getScaleFilter(builderResult.result),
|
||||||
|
options: `w=-2:h=${resolution}`,
|
||||||
|
outputs: `vout${resolution}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const streamType: StreamType = 'audio'
|
||||||
|
const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
|
||||||
|
if (!builderResult) {
|
||||||
|
throw new Error('No available live audio encoder found')
|
||||||
|
}
|
||||||
|
|
||||||
|
command.outputOption('-map a:0')
|
||||||
|
|
||||||
|
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
|
||||||
|
{ builderResult, fps: resolutionFPS, resolution, ...lTags() }
|
||||||
|
)
|
||||||
|
|
||||||
|
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
|
||||||
|
applyEncoderOptions(command, builderResult.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
varStreamMap.push(`v:${i},a:${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
command.complexFilter(complexFilter)
|
||||||
|
|
||||||
|
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
|
||||||
|
|
||||||
|
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
|
||||||
|
const command = getFFmpeg(inputUrl, 'live')
|
||||||
|
|
||||||
|
command.outputOption('-c:v copy')
|
||||||
|
command.outputOption('-c:a copy')
|
||||||
|
command.outputOption('-map 0:a?')
|
||||||
|
command.outputOption('-map 0:v?')
|
||||||
|
|
||||||
|
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
getLiveTranscodingCommand,
|
||||||
|
getLiveMuxingCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
|
||||||
|
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
|
||||||
|
command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
|
||||||
|
command.outputOption('-hls_flags delete_segments+independent_segments')
|
||||||
|
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
|
||||||
|
command.outputOption('-master_pl_name ' + masterPlaylistName)
|
||||||
|
command.outputOption(`-f hls`)
|
||||||
|
|
||||||
|
command.output(join(outPath, '%v.m3u8'))
|
||||||
|
}
|
156
server/helpers/ffmpeg/ffmpeg-presets.ts
Normal file
156
server/helpers/ffmpeg/ffmpeg-presets.ts
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
|
import { pick } from 'lodash'
|
||||||
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
|
import { AvailableEncoders, EncoderOptions } from '@shared/models'
|
||||||
|
import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
|
||||||
|
import { getEncoderBuilderResult } from './ffmpeg-encoders'
|
||||||
|
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('ffmpeg')
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
|
||||||
|
// avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
|
||||||
|
command.outputOption('-max_muxing_queue_size 1024')
|
||||||
|
// strip all metadata
|
||||||
|
.outputOption('-map_metadata -1')
|
||||||
|
// allows import of source material with incompatible pixel formats (e.g. MJPEG video)
|
||||||
|
.outputOption('-pix_fmt yuv420p')
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDefaultEncoderParams (options: {
|
||||||
|
command: FfmpegCommand
|
||||||
|
encoder: 'libx264' | string
|
||||||
|
fps: number
|
||||||
|
|
||||||
|
streamNum?: number
|
||||||
|
}) {
|
||||||
|
const { command, encoder, fps, streamNum } = options
|
||||||
|
|
||||||
|
if (encoder === 'libx264') {
|
||||||
|
// 3.1 is the minimal resource allocation for our highest supported resolution
|
||||||
|
command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
|
||||||
|
|
||||||
|
if (fps) {
|
||||||
|
// Keyframe interval of 2 seconds for faster seeking and resolution switching.
|
||||||
|
// https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
|
||||||
|
// https://superuser.com/a/908325
|
||||||
|
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function presetVOD (options: {
|
||||||
|
command: FfmpegCommand
|
||||||
|
input: string
|
||||||
|
|
||||||
|
availableEncoders: AvailableEncoders
|
||||||
|
profile: string
|
||||||
|
|
||||||
|
canCopyAudio: boolean
|
||||||
|
canCopyVideo: boolean
|
||||||
|
|
||||||
|
resolution: number
|
||||||
|
fps: number
|
||||||
|
|
||||||
|
scaleFilterValue?: string
|
||||||
|
}) {
|
||||||
|
const { command, input, profile, resolution, fps, scaleFilterValue } = options
|
||||||
|
|
||||||
|
let localCommand = command
|
||||||
|
.format('mp4')
|
||||||
|
.outputOption('-movflags faststart')
|
||||||
|
|
||||||
|
addDefaultEncoderGlobalParams(command)
|
||||||
|
|
||||||
|
const probe = await ffprobePromise(input)
|
||||||
|
|
||||||
|
// Audio encoder
|
||||||
|
const bitrate = await getVideoStreamBitrate(input, probe)
|
||||||
|
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
|
||||||
|
|
||||||
|
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
|
||||||
|
|
||||||
|
if (!await hasAudioStream(input, probe)) {
|
||||||
|
localCommand = localCommand.noAudio()
|
||||||
|
streamsToProcess = [ 'video' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const streamType of streamsToProcess) {
|
||||||
|
const builderResult = await getEncoderBuilderResult({
|
||||||
|
...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]),
|
||||||
|
|
||||||
|
input,
|
||||||
|
inputBitrate: bitrate,
|
||||||
|
inputRatio: videoStreamDimensions?.ratio || 0,
|
||||||
|
|
||||||
|
profile,
|
||||||
|
resolution,
|
||||||
|
fps,
|
||||||
|
streamType,
|
||||||
|
|
||||||
|
videoType: 'vod' as 'vod'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!builderResult) {
|
||||||
|
throw new Error('No available encoder found for stream ' + streamType)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
|
||||||
|
builderResult.encoder, streamType, input, profile,
|
||||||
|
{ builderResult, resolution, fps, ...lTags() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (streamType === 'video') {
|
||||||
|
localCommand.videoCodec(builderResult.encoder)
|
||||||
|
|
||||||
|
if (scaleFilterValue) {
|
||||||
|
localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
|
||||||
|
}
|
||||||
|
} else if (streamType === 'audio') {
|
||||||
|
localCommand.audioCodec(builderResult.encoder)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEncoderOptions(localCommand, builderResult.result)
|
||||||
|
addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
|
||||||
|
}
|
||||||
|
|
||||||
|
return localCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetCopy (command: FfmpegCommand): FfmpegCommand {
|
||||||
|
return command
|
||||||
|
.format('mp4')
|
||||||
|
.videoCodec('copy')
|
||||||
|
.audioCodec('copy')
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
|
||||||
|
return command
|
||||||
|
.format('mp4')
|
||||||
|
.audioCodec('copy')
|
||||||
|
.noVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
|
||||||
|
return command
|
||||||
|
.inputOptions(options.inputOptions ?? [])
|
||||||
|
.outputOptions(options.outputOptions ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
presetVOD,
|
||||||
|
presetCopy,
|
||||||
|
presetOnlyAudio,
|
||||||
|
|
||||||
|
addDefaultEncoderGlobalParams,
|
||||||
|
addDefaultEncoderParams,
|
||||||
|
|
||||||
|
applyEncoderOptions
|
||||||
|
}
|
254
server/helpers/ffmpeg/ffmpeg-vod.ts
Normal file
254
server/helpers/ffmpeg/ffmpeg-vod.ts
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
import { Job } from 'bull'
|
||||||
|
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
|
import { readFile, writeFile } from 'fs-extra'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
import { pick } from '@shared/core-utils'
|
||||||
|
import { AvailableEncoders, VideoResolution } from '@shared/models'
|
||||||
|
import { logger, loggerTagsFactory } from '../logger'
|
||||||
|
import { getFFmpeg, runCommand } from './ffmpeg-commons'
|
||||||
|
import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
|
||||||
|
import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
|
||||||
|
import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('ffmpeg')
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
|
||||||
|
|
||||||
|
interface BaseTranscodeVODOptions {
|
||||||
|
type: TranscodeVODOptionsType
|
||||||
|
|
||||||
|
inputPath: string
|
||||||
|
outputPath: string
|
||||||
|
|
||||||
|
availableEncoders: AvailableEncoders
|
||||||
|
profile: string
|
||||||
|
|
||||||
|
resolution: number
|
||||||
|
|
||||||
|
isPortraitMode?: boolean
|
||||||
|
|
||||||
|
job?: Job
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
|
type: 'hls'
|
||||||
|
copyCodecs: boolean
|
||||||
|
hlsPlaylist: {
|
||||||
|
videoFilename: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
|
type: 'hls-from-ts'
|
||||||
|
|
||||||
|
isAAC: boolean
|
||||||
|
|
||||||
|
hlsPlaylist: {
|
||||||
|
videoFilename: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
|
type: 'quick-transcode'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
|
type: 'video'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
|
type: 'merge-audio'
|
||||||
|
audioPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
|
||||||
|
type: 'only-audio'
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranscodeVODOptions =
|
||||||
|
HLSTranscodeOptions
|
||||||
|
| HLSFromTSTranscodeOptions
|
||||||
|
| VideoTranscodeOptions
|
||||||
|
| MergeAudioTranscodeOptions
|
||||||
|
| OnlyAudioTranscodeOptions
|
||||||
|
| QuickTranscodeOptions
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const builders: {
|
||||||
|
[ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
|
||||||
|
} = {
|
||||||
|
'quick-transcode': buildQuickTranscodeCommand,
|
||||||
|
'hls': buildHLSVODCommand,
|
||||||
|
'hls-from-ts': buildHLSVODFromTSCommand,
|
||||||
|
'merge-audio': buildAudioMergeCommand,
|
||||||
|
'only-audio': buildOnlyAudioCommand,
|
||||||
|
'video': buildVODCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcodeVOD (options: TranscodeVODOptions) {
|
||||||
|
logger.debug('Will run transcode.', { options, ...lTags() })
|
||||||
|
|
||||||
|
let command = getFFmpeg(options.inputPath, 'vod')
|
||||||
|
.output(options.outputPath)
|
||||||
|
|
||||||
|
command = await builders[options.type](command, options)
|
||||||
|
|
||||||
|
await runCommand({ command, job: options.job })
|
||||||
|
|
||||||
|
await fixHLSPlaylistIfNeeded(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
transcodeVOD,
|
||||||
|
|
||||||
|
buildVODCommand,
|
||||||
|
|
||||||
|
TranscodeVODOptions,
|
||||||
|
TranscodeVODOptionsType
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
|
||||||
|
let fps = await getVideoStreamFPS(options.inputPath)
|
||||||
|
fps = computeFPS(fps, options.resolution)
|
||||||
|
|
||||||
|
let scaleFilterValue: string
|
||||||
|
|
||||||
|
if (options.resolution !== undefined) {
|
||||||
|
scaleFilterValue = options.isPortraitMode === true
|
||||||
|
? `w=${options.resolution}:h=-2`
|
||||||
|
: `w=-2:h=${options.resolution}`
|
||||||
|
}
|
||||||
|
|
||||||
|
command = await presetVOD({
|
||||||
|
...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
|
||||||
|
|
||||||
|
command,
|
||||||
|
input: options.inputPath,
|
||||||
|
canCopyAudio: true,
|
||||||
|
canCopyVideo: true,
|
||||||
|
fps,
|
||||||
|
scaleFilterValue
|
||||||
|
})
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuickTranscodeCommand (command: FfmpegCommand) {
|
||||||
|
command = presetCopy(command)
|
||||||
|
|
||||||
|
command = command.outputOption('-map_metadata -1') // strip all metadata
|
||||||
|
.outputOption('-movflags faststart')
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio transcoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
|
||||||
|
command = command.loop(undefined)
|
||||||
|
|
||||||
|
const scaleFilterValue = getMergeAudioScaleFilterValue()
|
||||||
|
command = await presetVOD({
|
||||||
|
...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
|
||||||
|
|
||||||
|
command,
|
||||||
|
input: options.audioPath,
|
||||||
|
canCopyAudio: true,
|
||||||
|
canCopyVideo: true,
|
||||||
|
fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
|
||||||
|
scaleFilterValue
|
||||||
|
})
|
||||||
|
|
||||||
|
command.outputOption('-preset:v veryfast')
|
||||||
|
|
||||||
|
command = command.input(options.audioPath)
|
||||||
|
.outputOption('-tune stillimage')
|
||||||
|
.outputOption('-shortest')
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
|
||||||
|
command = presetOnlyAudio(command)
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HLS transcoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
|
||||||
|
const videoPath = getHLSVideoPath(options)
|
||||||
|
|
||||||
|
if (options.copyCodecs) command = presetCopy(command)
|
||||||
|
else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
|
||||||
|
else command = await buildVODCommand(command, options)
|
||||||
|
|
||||||
|
addCommonHLSVODCommandOptions(command, videoPath)
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
|
||||||
|
const videoPath = getHLSVideoPath(options)
|
||||||
|
|
||||||
|
command.outputOption('-c copy')
|
||||||
|
|
||||||
|
if (options.isAAC) {
|
||||||
|
// Required for example when copying an AAC stream from an MPEG-TS
|
||||||
|
// Since it's a bitstream filter, we don't need to reencode the audio
|
||||||
|
command.outputOption('-bsf:a aac_adtstoasc')
|
||||||
|
}
|
||||||
|
|
||||||
|
addCommonHLSVODCommandOptions(command, videoPath)
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
|
||||||
|
return command.outputOption('-hls_time 4')
|
||||||
|
.outputOption('-hls_list_size 0')
|
||||||
|
.outputOption('-hls_playlist_type vod')
|
||||||
|
.outputOption('-hls_segment_filename ' + outputPath)
|
||||||
|
.outputOption('-hls_segment_type fmp4')
|
||||||
|
.outputOption('-f hls')
|
||||||
|
.outputOption('-hls_flags single_file')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
|
||||||
|
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
|
||||||
|
|
||||||
|
const fileContent = await readFile(options.outputPath)
|
||||||
|
|
||||||
|
const videoFileName = options.hlsPlaylist.videoFilename
|
||||||
|
const videoFilePath = getHLSVideoPath(options)
|
||||||
|
|
||||||
|
// Fix wrong mapping with some ffmpeg versions
|
||||||
|
const newContent = fileContent.toString()
|
||||||
|
.replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
|
||||||
|
|
||||||
|
await writeFile(options.outputPath, newContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
|
||||||
|
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid "height not divisible by 2" error
|
||||||
|
function getMergeAudioScaleFilterValue () {
|
||||||
|
return 'trunc(iw/2)*2:trunc(ih/2)*2'
|
||||||
|
}
|
|
@ -1,22 +1,21 @@
|
||||||
import { FfprobeData } from 'fluent-ffmpeg'
|
import { FfprobeData } from 'fluent-ffmpeg'
|
||||||
import { getMaxBitrate } from '@shared/core-utils'
|
import { getMaxBitrate } from '@shared/core-utils'
|
||||||
import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos'
|
|
||||||
import { CONFIG } from '../initializers/config'
|
|
||||||
import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
|
|
||||||
import { logger } from './logger'
|
|
||||||
import {
|
import {
|
||||||
canDoQuickAudioTranscode,
|
|
||||||
ffprobePromise,
|
ffprobePromise,
|
||||||
getDurationFromVideoFile,
|
|
||||||
getAudioStream,
|
getAudioStream,
|
||||||
|
getVideoStreamDuration,
|
||||||
getMaxAudioBitrate,
|
getMaxAudioBitrate,
|
||||||
getMetadataFromFile,
|
buildFileMetadata,
|
||||||
getVideoFileBitrate,
|
getVideoStreamBitrate,
|
||||||
getVideoFileFPS,
|
getVideoStreamFPS,
|
||||||
getVideoFileResolution,
|
getVideoStream,
|
||||||
getVideoStreamFromFile,
|
getVideoStreamDimensionsInfo,
|
||||||
getVideoStreamSize
|
hasAudioStream
|
||||||
} from '@shared/extra-utils/ffprobe'
|
} from '@shared/extra-utils/ffprobe'
|
||||||
|
import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
|
||||||
|
import { CONFIG } from '../../initializers/config'
|
||||||
|
import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
|
||||||
|
import { logger } from '../logger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -24,9 +23,12 @@ import {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function getVideoStreamCodec (path: string) {
|
// ---------------------------------------------------------------------------
|
||||||
const videoStream = await getVideoStreamFromFile(path)
|
// Codecs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getVideoStreamCodec (path: string) {
|
||||||
|
const videoStream = await getVideoStream(path)
|
||||||
if (!videoStream) return ''
|
if (!videoStream) return ''
|
||||||
|
|
||||||
const videoCodec = videoStream.codec_tag_string
|
const videoCodec = videoStream.codec_tag_string
|
||||||
|
@ -83,6 +85,10 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
|
||||||
return 'mp4a.40.2' // Fallback
|
return 'mp4a.40.2' // Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolutions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
|
function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
|
||||||
const configResolutions = type === 'vod'
|
const configResolutions = type === 'vod'
|
||||||
? CONFIG.TRANSCODING.RESOLUTIONS
|
? CONFIG.TRANSCODING.RESOLUTIONS
|
||||||
|
@ -112,6 +118,10 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
|
||||||
return resolutionsEnabled
|
return resolutionsEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Can quick transcode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function canDoQuickTranscode (path: string): Promise<boolean> {
|
async function canDoQuickTranscode (path: string): Promise<boolean> {
|
||||||
if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
|
if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
|
||||||
|
|
||||||
|
@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
|
||||||
await canDoQuickAudioTranscode(path, probe)
|
await canDoQuickAudioTranscode(path, probe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||||
|
const parsedAudio = await getAudioStream(path, probe)
|
||||||
|
|
||||||
|
if (!parsedAudio.audioStream) return true
|
||||||
|
|
||||||
|
if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
|
||||||
|
|
||||||
|
const audioBitrate = parsedAudio.bitrate
|
||||||
|
if (!audioBitrate) return false
|
||||||
|
|
||||||
|
const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
|
||||||
|
if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
|
||||||
|
|
||||||
|
const channelLayout = parsedAudio.audioStream['channel_layout']
|
||||||
|
// Causes playback issues with Chrome
|
||||||
|
if (!channelLayout || channelLayout === 'unknown') return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||||
const videoStream = await getVideoStreamFromFile(path, probe)
|
const videoStream = await getVideoStream(path, probe)
|
||||||
const fps = await getVideoFileFPS(path, probe)
|
const fps = await getVideoStreamFPS(path, probe)
|
||||||
const bitRate = await getVideoFileBitrate(path, probe)
|
const bitRate = await getVideoStreamBitrate(path, probe)
|
||||||
const resolutionData = await getVideoFileResolution(path, probe)
|
const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
|
||||||
|
|
||||||
// If ffprobe did not manage to guess the bitrate
|
// If ffprobe did not manage to guess the bitrate
|
||||||
if (!bitRate) return false
|
if (!bitRate) return false
|
||||||
|
|
||||||
// check video params
|
// check video params
|
||||||
if (videoStream == null) return false
|
if (!videoStream) return false
|
||||||
if (videoStream['codec_name'] !== 'h264') return false
|
if (videoStream['codec_name'] !== 'h264') return false
|
||||||
if (videoStream['pix_fmt'] !== 'yuv420p') return false
|
if (videoStream['pix_fmt'] !== 'yuv420p') return false
|
||||||
if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
|
if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
|
||||||
|
@ -140,6 +170,10 @@ async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Pro
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Framerate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
|
function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
|
||||||
return VIDEO_TRANSCODING_FPS[type].slice(0)
|
return VIDEO_TRANSCODING_FPS[type].slice(0)
|
||||||
.sort((a, b) => fps % a - fps % b)[0]
|
.sort((a, b) => fps % a - fps % b)[0]
|
||||||
|
@ -171,21 +205,26 @@ function computeFPS (fpsArg: number, resolution: VideoResolution) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
// Re export ffprobe utils
|
||||||
|
getVideoStreamDimensionsInfo,
|
||||||
|
buildFileMetadata,
|
||||||
|
getMaxAudioBitrate,
|
||||||
|
getVideoStream,
|
||||||
|
getVideoStreamDuration,
|
||||||
|
getAudioStream,
|
||||||
|
hasAudioStream,
|
||||||
|
getVideoStreamFPS,
|
||||||
|
ffprobePromise,
|
||||||
|
getVideoStreamBitrate,
|
||||||
|
|
||||||
getVideoStreamCodec,
|
getVideoStreamCodec,
|
||||||
getAudioStreamCodec,
|
getAudioStreamCodec,
|
||||||
getVideoStreamSize,
|
|
||||||
getVideoFileResolution,
|
|
||||||
getMetadataFromFile,
|
|
||||||
getMaxAudioBitrate,
|
|
||||||
getVideoStreamFromFile,
|
|
||||||
getDurationFromVideoFile,
|
|
||||||
getAudioStream,
|
|
||||||
computeFPS,
|
computeFPS,
|
||||||
getVideoFileFPS,
|
|
||||||
ffprobePromise,
|
|
||||||
getClosestFramerateStandard,
|
getClosestFramerateStandard,
|
||||||
|
|
||||||
computeLowerResolutionsToTranscode,
|
computeLowerResolutionsToTranscode,
|
||||||
getVideoFileBitrate,
|
|
||||||
canDoQuickTranscode,
|
canDoQuickTranscode,
|
||||||
canDoQuickVideoTranscode,
|
canDoQuickVideoTranscode,
|
||||||
canDoQuickAudioTranscode
|
canDoQuickAudioTranscode
|
8
server/helpers/ffmpeg/index.ts
Normal file
8
server/helpers/ffmpeg/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
export * from './ffmpeg-commons'
|
||||||
|
export * from './ffmpeg-edition'
|
||||||
|
export * from './ffmpeg-encoders'
|
||||||
|
export * from './ffmpeg-images'
|
||||||
|
export * from './ffmpeg-live'
|
||||||
|
export * from './ffmpeg-presets'
|
||||||
|
export * from './ffmpeg-vod'
|
||||||
|
export * from './ffprobe-utils'
|
|
@ -1,9 +1,12 @@
|
||||||
import { copy, readFile, remove, rename } from 'fs-extra'
|
import { copy, readFile, remove, rename } from 'fs-extra'
|
||||||
import Jimp, { read } from 'jimp'
|
import Jimp, { read } from 'jimp'
|
||||||
|
import { join } from 'path'
|
||||||
import { getLowercaseExtension } from '@shared/core-utils'
|
import { getLowercaseExtension } from '@shared/core-utils'
|
||||||
import { buildUUID } from '@shared/extra-utils'
|
import { buildUUID } from '@shared/extra-utils'
|
||||||
import { convertWebPToJPG, processGIF } from './ffmpeg-utils'
|
import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
|
||||||
import { logger } from './logger'
|
import { logger, loggerTagsFactory } from './logger'
|
||||||
|
|
||||||
|
const lTags = loggerTagsFactory('image-utils')
|
||||||
|
|
||||||
function generateImageFilename (extension = '.jpg') {
|
function generateImageFilename (extension = '.jpg') {
|
||||||
return buildUUID() + extension
|
return buildUUID() + extension
|
||||||
|
@ -33,10 +36,31 @@ async function processImage (
|
||||||
if (keepOriginal !== true) await remove(path)
|
if (keepOriginal !== true) await remove(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
|
||||||
|
const pendingImageName = 'pending-' + imageName
|
||||||
|
const pendingImagePath = join(folder, pendingImageName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await generateThumbnailFromVideo(fromPath, folder, imageName)
|
||||||
|
|
||||||
|
const destination = join(folder, imageName)
|
||||||
|
await processImage(pendingImagePath, destination, size)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remove(pendingImagePath)
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
generateImageFilename,
|
generateImageFilename,
|
||||||
|
generateImageFromVideoFile,
|
||||||
processImage
|
processImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -91,6 +91,16 @@ async function downloadWebTorrentVideo (target: { uri: string, torrentName?: str
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
|
function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
|
||||||
|
return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => {
|
||||||
|
return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTorrentAndSetInfoHashFromPath (
|
||||||
|
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
|
||||||
|
videoFile: MVideoFile,
|
||||||
|
filePath: string
|
||||||
|
) {
|
||||||
const video = extractVideo(videoOrPlaylist)
|
const video = extractVideo(videoOrPlaylist)
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
|
@ -101,24 +111,22 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
|
||||||
urlList: buildUrlList(video, videoFile)
|
urlList: buildUrlList(video, videoFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), async videoPath => {
|
const torrentContent = await createTorrentPromise(filePath, options)
|
||||||
const torrentContent = await createTorrentPromise(videoPath, options)
|
|
||||||
|
|
||||||
const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
|
const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
|
||||||
const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
|
const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
|
||||||
logger.info('Creating torrent %s.', torrentPath)
|
logger.info('Creating torrent %s.', torrentPath)
|
||||||
|
|
||||||
await writeFile(torrentPath, torrentContent)
|
await writeFile(torrentPath, torrentContent)
|
||||||
|
|
||||||
// Remove old torrent file if it existed
|
// Remove old torrent file if it existed
|
||||||
if (videoFile.hasTorrent()) {
|
if (videoFile.hasTorrent()) {
|
||||||
await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
|
await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedTorrent = parseTorrent(torrentContent)
|
const parsedTorrent = parseTorrent(torrentContent)
|
||||||
videoFile.infoHash = parsedTorrent.infoHash
|
videoFile.infoHash = parsedTorrent.infoHash
|
||||||
videoFile.torrentFilename = torrentFilename
|
videoFile.torrentFilename = torrentFilename
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
|
async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
|
||||||
|
@ -177,7 +185,10 @@ function generateMagnetUri (
|
||||||
export {
|
export {
|
||||||
createTorrentPromise,
|
createTorrentPromise,
|
||||||
updateTorrentMetadata,
|
updateTorrentMetadata,
|
||||||
|
|
||||||
createTorrentAndSetInfoHash,
|
createTorrentAndSetInfoHash,
|
||||||
|
createTorrentAndSetInfoHashFromPath,
|
||||||
|
|
||||||
generateMagnetUri,
|
generateMagnetUri,
|
||||||
downloadWebTorrentVideo
|
downloadWebTorrentVideo
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import config from 'config'
|
import config from 'config'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { URL } from 'url'
|
import { URL } from 'url'
|
||||||
import { getFFmpegVersion } from '@server/helpers/ffmpeg-utils'
|
import { getFFmpegVersion } from '@server/helpers/ffmpeg'
|
||||||
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
|
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
|
||||||
import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
|
import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
|
||||||
import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
|
import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
|
||||||
|
@ -31,8 +31,7 @@ async function checkActivityPubUrls () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some checks on configuration files
|
// Some checks on configuration files or throw if there is an error
|
||||||
// Return an error message, or null if everything is okay
|
|
||||||
function checkConfig () {
|
function checkConfig () {
|
||||||
|
|
||||||
// Moved configuration keys
|
// Moved configuration keys
|
||||||
|
@ -40,157 +39,17 @@ function checkConfig () {
|
||||||
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
|
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email verification
|
checkEmailConfig()
|
||||||
if (!isEmailEnabled()) {
|
checkNSFWPolicyConfig()
|
||||||
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
|
checkLocalRedundancyConfig()
|
||||||
return 'Emailer is disabled but you require signup email verification.'
|
checkRemoteRedundancyConfig()
|
||||||
}
|
checkStorageConfig()
|
||||||
|
checkTranscodingConfig()
|
||||||
if (CONFIG.CONTACT_FORM.ENABLED) {
|
checkBroadcastMessageConfig()
|
||||||
logger.warn('Emailer is disabled so the contact form will not work.')
|
checkSearchConfig()
|
||||||
}
|
checkLiveConfig()
|
||||||
}
|
checkObjectStorageConfig()
|
||||||
|
checkVideoEditorConfig()
|
||||||
// NSFW policy
|
|
||||||
const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
|
|
||||||
{
|
|
||||||
const available = [ 'do_not_list', 'blur', 'display' ]
|
|
||||||
if (available.includes(defaultNSFWPolicy) === false) {
|
|
||||||
return 'NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redundancies
|
|
||||||
const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
|
|
||||||
if (isArray(redundancyVideos)) {
|
|
||||||
const available = [ 'most-views', 'trending', 'recently-added' ]
|
|
||||||
for (const r of redundancyVideos) {
|
|
||||||
if (available.includes(r.strategy) === false) {
|
|
||||||
return 'Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifetime should not be < 10 hours
|
|
||||||
if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) {
|
|
||||||
return 'Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = uniq(redundancyVideos.map(r => r.strategy))
|
|
||||||
if (filtered.length !== redundancyVideos.length) {
|
|
||||||
return 'Redundancy video entries should have unique strategies'
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
|
|
||||||
if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
|
|
||||||
return 'Min views in recently added strategy is not a number'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return 'Videos redundancy should be an array (you must uncomment lines containing - too)'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote redundancies
|
|
||||||
const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM
|
|
||||||
const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ])
|
|
||||||
if (acceptFromValues.has(acceptFrom) === false) {
|
|
||||||
return 'remote_redundancy.videos.accept_from has an incorrect value'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check storage directory locations
|
|
||||||
if (isProdInstance()) {
|
|
||||||
const configStorage = config.get('storage')
|
|
||||||
for (const key of Object.keys(configStorage)) {
|
|
||||||
if (configStorage[key].startsWith('storage/')) {
|
|
||||||
logger.warn(
|
|
||||||
'Directory of %s should not be in the production directory of PeerTube. Please check your production configuration file.',
|
|
||||||
key
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
|
|
||||||
logger.warn('Redundancy directory should be different than the videos folder.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transcoding
|
|
||||||
if (CONFIG.TRANSCODING.ENABLED) {
|
|
||||||
if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
|
|
||||||
return 'You need to enable at least WebTorrent transcoding or HLS transcoding.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
|
|
||||||
return 'Transcoding concurrency should be > 0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) {
|
|
||||||
if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) {
|
|
||||||
return 'Video import concurrency should be > 0'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast message
|
|
||||||
if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
|
|
||||||
const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
|
|
||||||
const available = [ 'info', 'warning', 'error' ]
|
|
||||||
|
|
||||||
if (available.includes(currentLevel) === false) {
|
|
||||||
return 'Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search index
|
|
||||||
if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
|
|
||||||
if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
|
|
||||||
return 'You cannot enable search index without enabling remote URI search for users.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live
|
|
||||||
if (CONFIG.LIVE.ENABLED === true) {
|
|
||||||
if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
|
|
||||||
return 'Live allow replay cannot be enabled if transcoding is not enabled.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) {
|
|
||||||
return 'You must enable at least RTMP or RTMPS'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CONFIG.LIVE.RTMPS.ENABLED) {
|
|
||||||
if (!CONFIG.LIVE.RTMPS.KEY_FILE) {
|
|
||||||
return 'You must specify a key file to enabled RTMPS'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CONFIG.LIVE.RTMPS.CERT_FILE) {
|
|
||||||
return 'You must specify a cert file to enable RTMPS'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object storage
|
|
||||||
if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
|
|
||||||
|
|
||||||
if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) {
|
|
||||||
return 'videos_bucket should be set when object storage support is enabled.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) {
|
|
||||||
return 'streaming_playlists_bucket should be set when object storage support is enabled.'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME &&
|
|
||||||
CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
|
|
||||||
) {
|
|
||||||
if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') {
|
|
||||||
return 'Object storage bucket prefixes should be set when the same bucket is used for both types of video.'
|
|
||||||
} else {
|
|
||||||
return 'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We get db by param to not import it in this file (import orders)
|
// We get db by param to not import it in this file (import orders)
|
||||||
|
@ -233,3 +92,176 @@ export {
|
||||||
applicationExist,
|
applicationExist,
|
||||||
checkActivityPubUrls
|
checkActivityPubUrls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function checkEmailConfig () {
|
||||||
|
if (!isEmailEnabled()) {
|
||||||
|
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
|
||||||
|
throw new Error('Emailer is disabled but you require signup email verification.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG.CONTACT_FORM.ENABLED) {
|
||||||
|
logger.warn('Emailer is disabled so the contact form will not work.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNSFWPolicyConfig () {
|
||||||
|
const defaultNSFWPolicy = CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
|
||||||
|
|
||||||
|
const available = [ 'do_not_list', 'blur', 'display' ]
|
||||||
|
if (available.includes(defaultNSFWPolicy) === false) {
|
||||||
|
throw new Error('NSFW policy setting should be ' + available.join(' or ') + ' instead of ' + defaultNSFWPolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLocalRedundancyConfig () {
|
||||||
|
const redundancyVideos = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
|
||||||
|
|
||||||
|
if (isArray(redundancyVideos)) {
|
||||||
|
const available = [ 'most-views', 'trending', 'recently-added' ]
|
||||||
|
|
||||||
|
for (const r of redundancyVideos) {
|
||||||
|
if (available.includes(r.strategy) === false) {
|
||||||
|
throw new Error('Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifetime should not be < 10 hours
|
||||||
|
if (!isTestInstance() && r.minLifetime < 1000 * 3600 * 10) {
|
||||||
|
throw new Error('Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = uniq(redundancyVideos.map(r => r.strategy))
|
||||||
|
if (filtered.length !== redundancyVideos.length) {
|
||||||
|
throw new Error('Redundancy video entries should have unique strategies')
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentlyAddedStrategy = redundancyVideos.find(r => r.strategy === 'recently-added') as RecentlyAddedStrategy
|
||||||
|
if (recentlyAddedStrategy && isNaN(recentlyAddedStrategy.minViews)) {
|
||||||
|
throw new Error('Min views in recently added strategy is not a number')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Videos redundancy should be an array (you must uncomment lines containing - too)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRemoteRedundancyConfig () {
|
||||||
|
const acceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM
|
||||||
|
const acceptFromValues = new Set<VideoRedundancyConfigFilter>([ 'nobody', 'anybody', 'followings' ])
|
||||||
|
|
||||||
|
if (acceptFromValues.has(acceptFrom) === false) {
|
||||||
|
throw new Error('remote_redundancy.videos.accept_from has an incorrect value')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStorageConfig () {
|
||||||
|
// Check storage directory locations
|
||||||
|
if (isProdInstance()) {
|
||||||
|
const configStorage = config.get('storage')
|
||||||
|
for (const key of Object.keys(configStorage)) {
|
||||||
|
if (configStorage[key].startsWith('storage/')) {
|
||||||
|
logger.warn(
|
||||||
|
'Directory of %s should not be in the production directory of PeerTube. Please check your production configuration file.',
|
||||||
|
key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
|
||||||
|
logger.warn('Redundancy directory should be different than the videos folder.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTranscodingConfig () {
|
||||||
|
if (CONFIG.TRANSCODING.ENABLED) {
|
||||||
|
if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
|
||||||
|
throw new Error('You need to enable at least WebTorrent transcoding or HLS transcoding.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
|
||||||
|
throw new Error('Transcoding concurrency should be > 0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED || CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED) {
|
||||||
|
if (CONFIG.IMPORT.VIDEOS.CONCURRENCY <= 0) {
|
||||||
|
throw new Error('Video import concurrency should be > 0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBroadcastMessageConfig () {
|
||||||
|
if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
|
||||||
|
const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
|
||||||
|
const available = [ 'info', 'warning', 'error' ]
|
||||||
|
|
||||||
|
if (available.includes(currentLevel) === false) {
|
||||||
|
throw new Error('Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSearchConfig () {
|
||||||
|
if (CONFIG.SEARCH.SEARCH_INDEX.ENABLED === true) {
|
||||||
|
if (CONFIG.SEARCH.REMOTE_URI.USERS === false) {
|
||||||
|
throw new Error('You cannot enable search index without enabling remote URI search for users.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLiveConfig () {
|
||||||
|
if (CONFIG.LIVE.ENABLED === true) {
|
||||||
|
if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
|
||||||
|
throw new Error('Live allow replay cannot be enabled if transcoding is not enabled.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG.LIVE.RTMP.ENABLED === false && CONFIG.LIVE.RTMPS.ENABLED === false) {
|
||||||
|
throw new Error('You must enable at least RTMP or RTMPS')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONFIG.LIVE.RTMPS.ENABLED) {
|
||||||
|
if (!CONFIG.LIVE.RTMPS.KEY_FILE) {
|
||||||
|
throw new Error('You must specify a key file to enabled RTMPS')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CONFIG.LIVE.RTMPS.CERT_FILE) {
|
||||||
|
throw new Error('You must specify a cert file to enable RTMPS')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkObjectStorageConfig () {
|
||||||
|
if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
|
||||||
|
|
||||||
|
if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) {
|
||||||
|
throw new Error('videos_bucket should be set when object storage support is enabled.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME) {
|
||||||
|
throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME &&
|
||||||
|
CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
|
||||||
|
) {
|
||||||
|
if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') {
|
||||||
|
throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'Object storage bucket prefixes should be set to different values when the same bucket is used for both types of video.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkVideoEditorConfig () {
|
||||||
|
if (CONFIG.VIDEO_EDITOR.ENABLED === true && CONFIG.TRANSCODING.ENABLED === false) {
|
||||||
|
throw new Error('Video editor cannot be enabled if transcoding is disabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ function checkMissedConfig () {
|
||||||
'transcoding.profile', 'transcoding.concurrency',
|
'transcoding.profile', 'transcoding.concurrency',
|
||||||
'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
|
'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
|
||||||
'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
|
'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
|
||||||
'transcoding.resolutions.2160p',
|
'transcoding.resolutions.2160p', 'video_editor.enabled',
|
||||||
'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
|
'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
|
||||||
'trending.videos.interval_days',
|
'trending.videos.interval_days',
|
||||||
'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
|
'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
|
||||||
|
|
|
@ -324,6 +324,9 @@ const CONFIG = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
VIDEO_EDITOR: {
|
||||||
|
get ENABLED () { return config.get<boolean>('video_editor.enabled') }
|
||||||
|
},
|
||||||
IMPORT: {
|
IMPORT: {
|
||||||
VIDEOS: {
|
VIDEOS: {
|
||||||
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
|
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
|
||||||
|
|
|
@ -152,6 +152,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
|
||||||
'activitypub-refresher': 1,
|
'activitypub-refresher': 1,
|
||||||
'video-redundancy': 1,
|
'video-redundancy': 1,
|
||||||
'video-live-ending': 1,
|
'video-live-ending': 1,
|
||||||
|
'video-edition': 1,
|
||||||
'move-to-object-storage': 3
|
'move-to-object-storage': 3
|
||||||
}
|
}
|
||||||
// Excluded keys are jobs that can be configured by admins
|
// Excluded keys are jobs that can be configured by admins
|
||||||
|
@ -168,6 +169,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
|
||||||
'activitypub-refresher': 1,
|
'activitypub-refresher': 1,
|
||||||
'video-redundancy': 1,
|
'video-redundancy': 1,
|
||||||
'video-live-ending': 10,
|
'video-live-ending': 10,
|
||||||
|
'video-edition': 1,
|
||||||
'move-to-object-storage': 1
|
'move-to-object-storage': 1
|
||||||
}
|
}
|
||||||
const JOB_TTL: { [id in JobType]: number } = {
|
const JOB_TTL: { [id in JobType]: number } = {
|
||||||
|
@ -178,6 +180,7 @@ const JOB_TTL: { [id in JobType]: number } = {
|
||||||
'activitypub-cleaner': 1000 * 3600, // 1 hour
|
'activitypub-cleaner': 1000 * 3600, // 1 hour
|
||||||
'video-file-import': 1000 * 3600, // 1 hour
|
'video-file-import': 1000 * 3600, // 1 hour
|
||||||
'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
|
'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
|
||||||
|
'video-edition': 1000 * 3600 * 10, // 10 hours
|
||||||
'video-import': 1000 * 3600 * 2, // 2 hours
|
'video-import': 1000 * 3600 * 2, // 2 hours
|
||||||
'email': 60000 * 10, // 10 minutes
|
'email': 60000 * 10, // 10 minutes
|
||||||
'actor-keys': 60000 * 20, // 20 minutes
|
'actor-keys': 60000 * 20, // 20 minutes
|
||||||
|
@ -351,6 +354,10 @@ const CONSTRAINTS_FIELDS = {
|
||||||
},
|
},
|
||||||
COMMONS: {
|
COMMONS: {
|
||||||
URL: { min: 5, max: 2000 } // Length
|
URL: { min: 5, max: 2000 } // Length
|
||||||
|
},
|
||||||
|
VIDEO_EDITOR: {
|
||||||
|
TASKS: { min: 1, max: 10 }, // Number of tasks
|
||||||
|
CUT_TIME: { min: 0 } // Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -365,6 +372,7 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
|
||||||
MIN: 1,
|
MIN: 1,
|
||||||
STANDARD: [ 24, 25, 30 ],
|
STANDARD: [ 24, 25, 30 ],
|
||||||
HD_STANDARD: [ 50, 60 ],
|
HD_STANDARD: [ 50, 60 ],
|
||||||
|
AUDIO_MERGE: 25,
|
||||||
AVERAGE: 30,
|
AVERAGE: 30,
|
||||||
MAX: 60,
|
MAX: 60,
|
||||||
KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
|
KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
|
||||||
|
@ -434,7 +442,8 @@ const VIDEO_STATES: { [ id in VideoState ]: string } = {
|
||||||
[VideoState.LIVE_ENDED]: 'Livestream ended',
|
[VideoState.LIVE_ENDED]: 'Livestream ended',
|
||||||
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
|
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
|
||||||
[VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
|
[VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
|
||||||
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed'
|
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed',
|
||||||
|
[VideoState.TO_EDIT]: 'To edit*'
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
|
const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
|
||||||
|
@ -855,6 +864,16 @@ const FILES_CONTENT_HASH = {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const VIDEO_FILTERS = {
|
||||||
|
WATERMARK: {
|
||||||
|
SIZE_RATIO: 1 / 10,
|
||||||
|
HORIZONTAL_MARGIN_RATIO: 1 / 20,
|
||||||
|
VERTICAL_MARGIN_RATIO: 1 / 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
WEBSERVER,
|
WEBSERVER,
|
||||||
API_VERSION,
|
API_VERSION,
|
||||||
|
@ -893,6 +912,7 @@ export {
|
||||||
PLUGIN_GLOBAL_CSS_FILE_NAME,
|
PLUGIN_GLOBAL_CSS_FILE_NAME,
|
||||||
PLUGIN_GLOBAL_CSS_PATH,
|
PLUGIN_GLOBAL_CSS_PATH,
|
||||||
PRIVATE_RSA_KEY_SIZE,
|
PRIVATE_RSA_KEY_SIZE,
|
||||||
|
VIDEO_FILTERS,
|
||||||
ROUTE_CACHE_LIFETIME,
|
ROUTE_CACHE_LIFETIME,
|
||||||
SORTABLE_COLUMNS,
|
SORTABLE_COLUMNS,
|
||||||
HLS_STREAMING_PLAYLIST_DIRECTORY,
|
HLS_STREAMING_PLAYLIST_DIRECTORY,
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import * as Sequelize from 'sequelize'
|
|
||||||
import { join } from 'path'
|
|
||||||
import { CONFIG } from '../../initializers/config'
|
|
||||||
import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
|
|
||||||
import { readdir, rename } from 'fs-extra'
|
import { readdir, rename } from 'fs-extra'
|
||||||
|
import { join } from 'path'
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg/ffprobe-utils'
|
||||||
|
import { CONFIG } from '../../initializers/config'
|
||||||
|
|
||||||
function up (utils: {
|
function up (utils: {
|
||||||
transaction: Sequelize.Transaction
|
transaction: Sequelize.Transaction
|
||||||
|
@ -26,7 +26,7 @@ function up (utils: {
|
||||||
const uuid = matches[1]
|
const uuid = matches[1]
|
||||||
const ext = matches[2]
|
const ext = matches[2]
|
||||||
|
|
||||||
const p = getVideoFileResolution(join(videoFileDir, videoFile))
|
const p = getVideoStreamDimensionsInfo(join(videoFileDir, videoFile))
|
||||||
.then(async ({ resolution }) => {
|
.then(async ({ resolution }) => {
|
||||||
const oldTorrentName = uuid + '.torrent'
|
const oldTorrentName = uuid + '.torrent'
|
||||||
const newTorrentName = uuid + '-' + resolution + '.torrent'
|
const newTorrentName = uuid + '-' + resolution + '.torrent'
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { basename, dirname, join } from 'path'
|
||||||
import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models'
|
import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models'
|
||||||
import { sha256 } from '@shared/extra-utils'
|
import { sha256 } from '@shared/extra-utils'
|
||||||
import { VideoStorage } from '@shared/models'
|
import { VideoStorage } from '@shared/models'
|
||||||
import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamSize } from '../helpers/ffprobe-utils'
|
import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
|
||||||
import { logger } from '../helpers/logger'
|
import { logger } from '../helpers/logger'
|
||||||
import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
|
import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
|
||||||
import { generateRandomString } from '../helpers/utils'
|
import { generateRandomString } from '../helpers/utils'
|
||||||
|
@ -40,10 +40,10 @@ async function updateMasterHLSPlaylist (video: MVideo, playlist: MStreamingPlayl
|
||||||
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
||||||
|
|
||||||
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
||||||
const size = await getVideoStreamSize(videoFilePath)
|
const size = await getVideoStreamDimensionsInfo(videoFilePath)
|
||||||
|
|
||||||
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
|
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
|
||||||
const resolution = `RESOLUTION=${size.width}x${size.height}`
|
const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
|
||||||
|
|
||||||
let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
|
let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
|
||||||
if (file.fps) line += ',FRAME-RATE=' + file.fps
|
if (file.fps) line += ',FRAME-RATE=' + file.fps
|
||||||
|
|
229
server/lib/job-queue/handlers/video-edition.ts
Normal file
229
server/lib/job-queue/handlers/video-edition.ts
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
import { Job } from 'bull'
|
||||||
|
import { move, remove } from 'fs-extra'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg'
|
||||||
|
import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
|
||||||
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
||||||
|
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
|
||||||
|
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
|
||||||
|
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||||
|
import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video'
|
||||||
|
import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor'
|
||||||
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
|
import { buildNextVideoState } from '@server/lib/video-state'
|
||||||
|
import { UserModel } from '@server/models/user/user'
|
||||||
|
import { VideoModel } from '@server/models/video/video'
|
||||||
|
import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
|
import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
|
||||||
|
import { getLowercaseExtension, pick } from '@shared/core-utils'
|
||||||
|
import {
|
||||||
|
buildFileMetadata,
|
||||||
|
buildUUID,
|
||||||
|
ffprobePromise,
|
||||||
|
getFileSize,
|
||||||
|
getVideoStreamDimensionsInfo,
|
||||||
|
getVideoStreamDuration,
|
||||||
|
getVideoStreamFPS
|
||||||
|
} from '@shared/extra-utils'
|
||||||
|
import {
|
||||||
|
VideoEditionPayload,
|
||||||
|
VideoEditionTaskPayload,
|
||||||
|
VideoEditorTask,
|
||||||
|
VideoEditorTaskCutPayload,
|
||||||
|
VideoEditorTaskIntroPayload,
|
||||||
|
VideoEditorTaskOutroPayload,
|
||||||
|
VideoEditorTaskWatermarkPayload,
|
||||||
|
VideoState
|
||||||
|
} from '@shared/models'
|
||||||
|
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||||
|
|
||||||
|
const lTagsBase = loggerTagsFactory('video-edition')
|
||||||
|
|
||||||
|
async function processVideoEdition (job: Job) {
|
||||||
|
const payload = job.data as VideoEditionPayload
|
||||||
|
|
||||||
|
logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
|
||||||
|
|
||||||
|
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
|
||||||
|
|
||||||
|
// No video, maybe deleted?
|
||||||
|
if (!video) {
|
||||||
|
logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkUserQuotaOrThrow(video, payload)
|
||||||
|
|
||||||
|
const inputFile = video.getMaxQualityFile()
|
||||||
|
|
||||||
|
const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
|
||||||
|
let tmpInputFilePath: string
|
||||||
|
let outputPath: string
|
||||||
|
|
||||||
|
for (const task of payload.tasks) {
|
||||||
|
const outputFilename = buildUUID() + inputFile.extname
|
||||||
|
outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
|
||||||
|
|
||||||
|
await processTask({
|
||||||
|
inputPath: tmpInputFilePath ?? originalFilePath,
|
||||||
|
video,
|
||||||
|
outputPath,
|
||||||
|
task
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tmpInputFilePath) await remove(tmpInputFilePath)
|
||||||
|
|
||||||
|
// For the next iteration
|
||||||
|
tmpInputFilePath = outputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Video edition ended for video %s.', video.uuid)
|
||||||
|
|
||||||
|
const newFile = await buildNewFile(video, editionResultPath)
|
||||||
|
|
||||||
|
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
|
||||||
|
await move(editionResultPath, outputPath)
|
||||||
|
|
||||||
|
await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
|
||||||
|
|
||||||
|
await removeAllFiles(video, newFile)
|
||||||
|
|
||||||
|
await newFile.save()
|
||||||
|
|
||||||
|
video.state = buildNextVideoState()
|
||||||
|
video.duration = await getVideoStreamDuration(outputPath)
|
||||||
|
await video.save()
|
||||||
|
|
||||||
|
await federateVideoIfNeeded(video, false, undefined)
|
||||||
|
|
||||||
|
if (video.state === VideoState.TO_TRANSCODE) {
|
||||||
|
const user = await UserModel.loadByVideoId(video.id)
|
||||||
|
|
||||||
|
await addOptimizeOrMergeAudioJob(video, newFile, user, false)
|
||||||
|
} else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
|
||||||
|
await addMoveToObjectStorageJob(video, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
processVideoEdition
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
|
||||||
|
inputPath: string
|
||||||
|
outputPath: string
|
||||||
|
video: MVideo
|
||||||
|
task: T
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
|
||||||
|
'add-intro': processAddIntroOutro,
|
||||||
|
'add-outro': processAddIntroOutro,
|
||||||
|
'cut': processCut,
|
||||||
|
'add-watermark': processAddWatermark
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processTask (options: TaskProcessorOptions) {
|
||||||
|
const { video, task } = options
|
||||||
|
|
||||||
|
logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
|
||||||
|
|
||||||
|
const processor = taskProcessors[options.task.name]
|
||||||
|
if (!process) throw new Error('Unknown task ' + task.name)
|
||||||
|
|
||||||
|
return processor(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
|
||||||
|
const { task } = options
|
||||||
|
|
||||||
|
return addIntroOutro({
|
||||||
|
...pick(options, [ 'inputPath', 'outputPath' ]),
|
||||||
|
|
||||||
|
introOutroPath: task.options.file,
|
||||||
|
type: task.name === 'add-intro'
|
||||||
|
? 'intro'
|
||||||
|
: 'outro',
|
||||||
|
|
||||||
|
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
|
||||||
|
profile: CONFIG.TRANSCODING.PROFILE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) {
|
||||||
|
const { task } = options
|
||||||
|
|
||||||
|
return cutVideo({
|
||||||
|
...pick(options, [ 'inputPath', 'outputPath' ]),
|
||||||
|
|
||||||
|
start: task.options.start,
|
||||||
|
end: task.options.end
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
|
||||||
|
const { task } = options
|
||||||
|
|
||||||
|
return addWatermark({
|
||||||
|
...pick(options, [ 'inputPath', 'outputPath' ]),
|
||||||
|
|
||||||
|
watermarkPath: task.options.file,
|
||||||
|
|
||||||
|
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
|
||||||
|
profile: CONFIG.TRANSCODING.PROFILE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildNewFile (video: MVideoId, path: string) {
|
||||||
|
const videoFile = new VideoFileModel({
|
||||||
|
extname: getLowercaseExtension(path),
|
||||||
|
size: await getFileSize(path),
|
||||||
|
metadata: await buildFileMetadata(path),
|
||||||
|
videoStreamingPlaylistId: null,
|
||||||
|
videoId: video.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const probe = await ffprobePromise(path)
|
||||||
|
|
||||||
|
videoFile.fps = await getVideoStreamFPS(path, probe)
|
||||||
|
videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
|
||||||
|
|
||||||
|
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
|
||||||
|
|
||||||
|
return videoFile
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
|
||||||
|
const hls = video.getHLSPlaylist()
|
||||||
|
|
||||||
|
if (hls) {
|
||||||
|
await video.removeStreamingPlaylistFiles(hls)
|
||||||
|
await hls.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of video.VideoFiles) {
|
||||||
|
if (file.id === webTorrentFileException.id) continue
|
||||||
|
|
||||||
|
await video.removeWebTorrentFileAndTorrent(file)
|
||||||
|
await file.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) {
|
||||||
|
const user = await UserModel.loadByVideoId(video.id)
|
||||||
|
|
||||||
|
const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file
|
||||||
|
|
||||||
|
const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
|
||||||
|
if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
|
||||||
|
throw new Error('Quota exceeded for this user to edit the video')
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,18 +1,18 @@
|
||||||
import { Job } from 'bull'
|
import { Job } from 'bull'
|
||||||
import { copy, stat } from 'fs-extra'
|
import { copy, stat } from 'fs-extra'
|
||||||
import { getLowercaseExtension } from '@shared/core-utils'
|
|
||||||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
||||||
import { CONFIG } from '@server/initializers/config'
|
import { CONFIG } from '@server/initializers/config'
|
||||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
||||||
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
|
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
|
||||||
import { addMoveToObjectStorageJob } from '@server/lib/video'
|
import { addMoveToObjectStorageJob } from '@server/lib/video'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
|
import { VideoModel } from '@server/models/video/video'
|
||||||
|
import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
import { MVideoFullLight } from '@server/types/models'
|
import { MVideoFullLight } from '@server/types/models'
|
||||||
|
import { getLowercaseExtension } from '@shared/core-utils'
|
||||||
import { VideoFileImportPayload, VideoStorage } from '@shared/models'
|
import { VideoFileImportPayload, VideoStorage } from '@shared/models'
|
||||||
import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
|
import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
|
||||||
import { VideoFileModel } from '../../../models/video/video-file'
|
|
||||||
|
|
||||||
async function processVideoFileImport (job: Job) {
|
async function processVideoFileImport (job: Job) {
|
||||||
const payload = job.data as VideoFileImportPayload
|
const payload = job.data as VideoFileImportPayload
|
||||||
|
@ -45,9 +45,9 @@ export {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
|
async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
|
||||||
const { resolution } = await getVideoFileResolution(inputFilePath)
|
const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath)
|
||||||
const { size } = await stat(inputFilePath)
|
const { size } = await stat(inputFilePath)
|
||||||
const fps = await getVideoFileFPS(inputFilePath)
|
const fps = await getVideoStreamFPS(inputFilePath)
|
||||||
|
|
||||||
const fileExt = getLowercaseExtension(inputFilePath)
|
const fileExt = getLowercaseExtension(inputFilePath)
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {
|
||||||
VideoResolution,
|
VideoResolution,
|
||||||
VideoState
|
VideoState
|
||||||
} from '@shared/models'
|
} from '@shared/models'
|
||||||
import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
|
import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { getSecureTorrentName } from '../../../helpers/utils'
|
import { getSecureTorrentName } from '../../../helpers/utils'
|
||||||
import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
|
import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
|
||||||
|
@ -121,10 +121,10 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
|
||||||
|
|
||||||
const { resolution } = await isAudioFile(tempVideoPath, probe)
|
const { resolution } = await isAudioFile(tempVideoPath, probe)
|
||||||
? { resolution: VideoResolution.H_NOVIDEO }
|
? { resolution: VideoResolution.H_NOVIDEO }
|
||||||
: await getVideoFileResolution(tempVideoPath)
|
: await getVideoStreamDimensionsInfo(tempVideoPath)
|
||||||
|
|
||||||
const fps = await getVideoFileFPS(tempVideoPath, probe)
|
const fps = await getVideoStreamFPS(tempVideoPath, probe)
|
||||||
const duration = await getDurationFromVideoFile(tempVideoPath, probe)
|
const duration = await getVideoStreamDuration(tempVideoPath, probe)
|
||||||
|
|
||||||
// Prepare video file object for creation in database
|
// Prepare video file object for creation in database
|
||||||
const fileExt = getLowercaseExtension(tempVideoPath)
|
const fileExt = getLowercaseExtension(tempVideoPath)
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { Job } from 'bull'
|
import { Job } from 'bull'
|
||||||
import { pathExists, readdir, remove } from 'fs-extra'
|
import { pathExists, readdir, remove } from 'fs-extra'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
|
import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
|
||||||
import { VIDEO_LIVE } from '@server/initializers/constants'
|
import { VIDEO_LIVE } from '@server/initializers/constants'
|
||||||
import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
|
import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
|
||||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths'
|
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths'
|
||||||
import { generateVideoMiniature } from '@server/lib/thumbnail'
|
import { generateVideoMiniature } from '@server/lib/thumbnail'
|
||||||
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding'
|
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
import { moveToNextState } from '@server/lib/video-state'
|
import { moveToNextState } from '@server/lib/video-state'
|
||||||
import { VideoModel } from '@server/models/video/video'
|
import { VideoModel } from '@server/models/video/video'
|
||||||
|
@ -96,7 +96,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
|
||||||
const probe = await ffprobePromise(concatenatedTsFilePath)
|
const probe = await ffprobePromise(concatenatedTsFilePath)
|
||||||
const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
|
const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
|
||||||
|
|
||||||
const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe)
|
const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
|
||||||
|
|
||||||
const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
|
const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
|
||||||
video: videoWithFiles,
|
video: videoWithFiles,
|
||||||
|
@ -107,7 +107,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!durationDone) {
|
if (!durationDone) {
|
||||||
videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
|
videoWithFiles.duration = await getVideoStreamDuration(outputPath)
|
||||||
await videoWithFiles.save()
|
await videoWithFiles.save()
|
||||||
|
|
||||||
durationDone = true
|
durationDone = true
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Job } from 'bull'
|
import { Job } from 'bull'
|
||||||
import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils'
|
import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg'
|
||||||
import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
|
import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
|
||||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
|
import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
|
||||||
|
@ -16,7 +16,7 @@ import {
|
||||||
VideoTranscodingPayload
|
VideoTranscodingPayload
|
||||||
} from '@shared/models'
|
} from '@shared/models'
|
||||||
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
||||||
import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils'
|
import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg'
|
||||||
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||||
import { CONFIG } from '../../../initializers/config'
|
import { CONFIG } from '../../../initializers/config'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
import { VideoModel } from '../../../models/video/video'
|
||||||
|
@ -25,7 +25,7 @@ import {
|
||||||
mergeAudioVideofile,
|
mergeAudioVideofile,
|
||||||
optimizeOriginalVideofile,
|
optimizeOriginalVideofile,
|
||||||
transcodeNewWebTorrentResolution
|
transcodeNewWebTorrentResolution
|
||||||
} from '../../transcoding/video-transcoding'
|
} from '../../transcoding/transcoding'
|
||||||
|
|
||||||
type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
|
type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
|
||||||
|
|
||||||
|
@ -174,10 +174,10 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
|
||||||
async function onVideoFirstWebTorrentTranscoding (
|
async function onVideoFirstWebTorrentTranscoding (
|
||||||
videoArg: MVideoWithFile,
|
videoArg: MVideoWithFile,
|
||||||
payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload,
|
payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload,
|
||||||
transcodeType: TranscodeOptionsType,
|
transcodeType: TranscodeVODOptionsType,
|
||||||
user: MUserId
|
user: MUserId
|
||||||
) {
|
) {
|
||||||
const { resolution, isPortraitMode, audioStream } = await videoArg.getMaxQualityFileInfo()
|
const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile()
|
||||||
|
|
||||||
// Maybe the video changed in database, refresh it
|
// Maybe the video changed in database, refresh it
|
||||||
const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid)
|
const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
JobType,
|
JobType,
|
||||||
MoveObjectStoragePayload,
|
MoveObjectStoragePayload,
|
||||||
RefreshPayload,
|
RefreshPayload,
|
||||||
|
VideoEditionPayload,
|
||||||
VideoFileImportPayload,
|
VideoFileImportPayload,
|
||||||
VideoImportPayload,
|
VideoImportPayload,
|
||||||
VideoLiveEndingPayload,
|
VideoLiveEndingPayload,
|
||||||
|
@ -31,6 +32,7 @@ import { refreshAPObject } from './handlers/activitypub-refresher'
|
||||||
import { processActorKeys } from './handlers/actor-keys'
|
import { processActorKeys } from './handlers/actor-keys'
|
||||||
import { processEmail } from './handlers/email'
|
import { processEmail } from './handlers/email'
|
||||||
import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
|
import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
|
||||||
|
import { processVideoEdition } from './handlers/video-edition'
|
||||||
import { processVideoFileImport } from './handlers/video-file-import'
|
import { processVideoFileImport } from './handlers/video-file-import'
|
||||||
import { processVideoImport } from './handlers/video-import'
|
import { processVideoImport } from './handlers/video-import'
|
||||||
import { processVideoLiveEnding } from './handlers/video-live-ending'
|
import { processVideoLiveEnding } from './handlers/video-live-ending'
|
||||||
|
@ -53,6 +55,7 @@ type CreateJobArgument =
|
||||||
{ type: 'actor-keys', payload: ActorKeysPayload } |
|
{ type: 'actor-keys', payload: ActorKeysPayload } |
|
||||||
{ type: 'video-redundancy', payload: VideoRedundancyPayload } |
|
{ type: 'video-redundancy', payload: VideoRedundancyPayload } |
|
||||||
{ type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
|
{ type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
|
||||||
|
{ type: 'video-edition', payload: VideoEditionPayload } |
|
||||||
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
|
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
|
||||||
|
|
||||||
export type CreateJobOptions = {
|
export type CreateJobOptions = {
|
||||||
|
@ -75,7 +78,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
|
||||||
'video-live-ending': processVideoLiveEnding,
|
'video-live-ending': processVideoLiveEnding,
|
||||||
'actor-keys': processActorKeys,
|
'actor-keys': processActorKeys,
|
||||||
'video-redundancy': processVideoRedundancy,
|
'video-redundancy': processVideoRedundancy,
|
||||||
'move-to-object-storage': processMoveToObjectStorage
|
'move-to-object-storage': processMoveToObjectStorage,
|
||||||
|
'video-edition': processVideoEdition
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobTypes: JobType[] = [
|
const jobTypes: JobType[] = [
|
||||||
|
@ -93,7 +97,8 @@ const jobTypes: JobType[] = [
|
||||||
'video-redundancy',
|
'video-redundancy',
|
||||||
'actor-keys',
|
'actor-keys',
|
||||||
'video-live-ending',
|
'video-live-ending',
|
||||||
'move-to-object-storage'
|
'move-to-object-storage',
|
||||||
|
'video-edition'
|
||||||
]
|
]
|
||||||
|
|
||||||
class JobQueue {
|
class JobQueue {
|
||||||
|
|
|
@ -5,10 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
|
||||||
import {
|
import {
|
||||||
computeLowerResolutionsToTranscode,
|
computeLowerResolutionsToTranscode,
|
||||||
ffprobePromise,
|
ffprobePromise,
|
||||||
getVideoFileBitrate,
|
getVideoStreamBitrate,
|
||||||
getVideoFileFPS,
|
getVideoStreamFPS,
|
||||||
getVideoFileResolution
|
getVideoStreamDimensionsInfo
|
||||||
} from '@server/helpers/ffprobe-utils'
|
} from '@server/helpers/ffmpeg'
|
||||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
|
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
|
||||||
import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
|
import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
|
||||||
|
@ -226,9 +226,9 @@ class LiveManager {
|
||||||
const probe = await ffprobePromise(inputUrl)
|
const probe = await ffprobePromise(inputUrl)
|
||||||
|
|
||||||
const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([
|
const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([
|
||||||
getVideoFileResolution(inputUrl, probe),
|
getVideoStreamDimensionsInfo(inputUrl, probe),
|
||||||
getVideoFileFPS(inputUrl, probe),
|
getVideoStreamFPS(inputUrl, probe),
|
||||||
getVideoFileBitrate(inputUrl, probe)
|
getVideoStreamBitrate(inputUrl, probe)
|
||||||
])
|
])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
@ -5,14 +5,14 @@ import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
|
import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { EventEmitter } from 'stream'
|
import { EventEmitter } from 'stream'
|
||||||
import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils'
|
import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg'
|
||||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
|
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
|
||||||
import { CONFIG } from '@server/initializers/config'
|
import { CONFIG } from '@server/initializers/config'
|
||||||
import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
|
import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file'
|
import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
|
import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
|
||||||
import { getLiveDirectory } from '../../paths'
|
import { getLiveDirectory } from '../../paths'
|
||||||
import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles'
|
import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles'
|
||||||
import { isAbleToUploadVideo } from '../../user'
|
import { isAbleToUploadVideo } from '../../user'
|
||||||
import { LiveQuotaStore } from '../live-quota-store'
|
import { LiveQuotaStore } from '../live-quota-store'
|
||||||
import { LiveSegmentShaStore } from '../live-segment-sha-store'
|
import { LiveSegmentShaStore } from '../live-segment-sha-store'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { ffprobePromise } from '@server/helpers/ffprobe-utils'
|
import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils'
|
||||||
import { buildLogger } from '@server/helpers/logger'
|
import { buildLogger } from '@server/helpers/logger'
|
||||||
import { CONFIG } from '@server/initializers/config'
|
import { CONFIG } from '@server/initializers/config'
|
||||||
import { WEBSERVER } from '@server/initializers/constants'
|
import { WEBSERVER } from '@server/initializers/constants'
|
||||||
|
|
|
@ -21,7 +21,7 @@ import {
|
||||||
VideoPlaylistPrivacy,
|
VideoPlaylistPrivacy,
|
||||||
VideoPrivacy
|
VideoPrivacy
|
||||||
} from '@shared/models'
|
} from '@shared/models'
|
||||||
import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles'
|
import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles'
|
||||||
import { buildPluginHelpers } from './plugin-helpers-builder'
|
import { buildPluginHelpers } from './plugin-helpers-builder'
|
||||||
|
|
||||||
export class RegisterHelpers {
|
export class RegisterHelpers {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuth
|
||||||
import { Hooks } from './plugins/hooks'
|
import { Hooks } from './plugins/hooks'
|
||||||
import { PluginManager } from './plugins/plugin-manager'
|
import { PluginManager } from './plugins/plugin-manager'
|
||||||
import { getThemeOrDefault } from './plugins/theme-utils'
|
import { getThemeOrDefault } from './plugins/theme-utils'
|
||||||
import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
|
import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -151,6 +151,9 @@ class ServerConfigManager {
|
||||||
port: CONFIG.LIVE.RTMP.PORT
|
port: CONFIG.LIVE.RTMP.PORT
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoEditor: {
|
||||||
|
enabled: CONFIG.VIDEO_EDITOR.ENABLED
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
http: {
|
http: {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
|
import { ThumbnailType } from '@shared/models'
|
||||||
import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
|
import { generateImageFilename, generateImageFromVideoFile, processImage } from '../helpers/image-utils'
|
||||||
import { generateImageFilename, processImage } from '../helpers/image-utils'
|
|
||||||
import { downloadImage } from '../helpers/requests'
|
import { downloadImage } from '../helpers/requests'
|
||||||
import { CONFIG } from '../initializers/config'
|
import { CONFIG } from '../initializers/config'
|
||||||
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
|
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
|
||||||
|
|
|
@ -2,8 +2,14 @@
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
|
import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
|
||||||
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
|
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
|
||||||
import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils'
|
import {
|
||||||
import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '../../helpers/ffprobe-utils'
|
buildStreamSuffix,
|
||||||
|
canDoQuickAudioTranscode,
|
||||||
|
ffprobePromise,
|
||||||
|
getAudioStream,
|
||||||
|
getMaxAudioBitrate,
|
||||||
|
resetSupportedEncoders
|
||||||
|
} from '../../helpers/ffmpeg'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -15,8 +21,14 @@ import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBi
|
||||||
* * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
* * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
||||||
const { fps, inputRatio, inputBitrate, resolution } = options
|
const { fps, inputRatio, inputBitrate, resolution } = options
|
||||||
|
|
||||||
|
// TODO: remove in 4.2, fps is not optional anymore
|
||||||
if (!fps) return { outputOptions: [ ] }
|
if (!fps) return { outputOptions: [ ] }
|
||||||
|
|
||||||
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
||||||
|
@ -45,10 +57,10 @@ const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum }) => {
|
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => {
|
||||||
const probe = await ffprobePromise(input)
|
const probe = await ffprobePromise(input)
|
||||||
|
|
||||||
if (await canDoQuickAudioTranscode(input, probe)) {
|
if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) {
|
||||||
logger.debug('Copy audio stream %s by AAC encoder.', input)
|
logger.debug('Copy audio stream %s by AAC encoder.', input)
|
||||||
return { copy: true, outputOptions: [ ] }
|
return { copy: true, outputOptions: [ ] }
|
||||||
}
|
}
|
||||||
|
@ -75,7 +87,10 @@ const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum })
|
||||||
return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
|
return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used to get and update available encoders
|
// ---------------------------------------------------------------------------
|
||||||
|
// Profile manager to get and change default profiles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class VideoTranscodingProfilesManager {
|
class VideoTranscodingProfilesManager {
|
||||||
private static instance: VideoTranscodingProfilesManager
|
private static instance: VideoTranscodingProfilesManager
|
||||||
|
|
|
@ -6,8 +6,15 @@ import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
||||||
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
|
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||||
import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
|
import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
|
||||||
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
|
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
|
||||||
import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils'
|
import {
|
||||||
import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils'
|
canDoQuickTranscode,
|
||||||
|
getVideoStreamDuration,
|
||||||
|
buildFileMetadata,
|
||||||
|
getVideoStreamFPS,
|
||||||
|
transcodeVOD,
|
||||||
|
TranscodeVODOptions,
|
||||||
|
TranscodeVODOptionsType
|
||||||
|
} from '../../helpers/ffmpeg'
|
||||||
import { CONFIG } from '../../initializers/config'
|
import { CONFIG } from '../../initializers/config'
|
||||||
import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
|
import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
|
||||||
import { VideoFileModel } from '../../models/video/video-file'
|
import { VideoFileModel } from '../../models/video/video-file'
|
||||||
|
@ -21,7 +28,7 @@ import {
|
||||||
getHlsResolutionPlaylistFilename
|
getHlsResolutionPlaylistFilename
|
||||||
} from '../paths'
|
} from '../paths'
|
||||||
import { VideoPathManager } from '../video-path-manager'
|
import { VideoPathManager } from '../video-path-manager'
|
||||||
import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
|
import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -38,13 +45,13 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
|
||||||
return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => {
|
return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => {
|
||||||
const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||||
|
|
||||||
const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath)
|
const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
|
||||||
? 'quick-transcode'
|
? 'quick-transcode'
|
||||||
: 'video'
|
: 'video'
|
||||||
|
|
||||||
const resolution = toEven(inputVideoFile.resolution)
|
const resolution = toEven(inputVideoFile.resolution)
|
||||||
|
|
||||||
const transcodeOptions: TranscodeOptions = {
|
const transcodeOptions: TranscodeVODOptions = {
|
||||||
type: transcodeType,
|
type: transcodeType,
|
||||||
|
|
||||||
inputPath: videoInputPath,
|
inputPath: videoInputPath,
|
||||||
|
@ -59,7 +66,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Could be very long!
|
// Could be very long!
|
||||||
await transcode(transcodeOptions)
|
await transcodeVOD(transcodeOptions)
|
||||||
|
|
||||||
// Important to do this before getVideoFilename() to take in account the new filename
|
// Important to do this before getVideoFilename() to take in account the new filename
|
||||||
inputVideoFile.extname = newExtname
|
inputVideoFile.extname = newExtname
|
||||||
|
@ -121,7 +128,7 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
|
||||||
job
|
job
|
||||||
}
|
}
|
||||||
|
|
||||||
await transcode(transcodeOptions)
|
await transcodeVOD(transcodeOptions)
|
||||||
|
|
||||||
return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
|
return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
|
||||||
})
|
})
|
||||||
|
@ -158,7 +165,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transcode(transcodeOptions)
|
await transcodeVOD(transcodeOptions)
|
||||||
|
|
||||||
await remove(audioInputPath)
|
await remove(audioInputPath)
|
||||||
await remove(tmpPreviewPath)
|
await remove(tmpPreviewPath)
|
||||||
|
@ -175,7 +182,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
|
||||||
const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile)
|
const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile)
|
||||||
// ffmpeg generated a new video file, so update the video duration
|
// ffmpeg generated a new video file, so update the video duration
|
||||||
// See https://trac.ffmpeg.org/ticket/5456
|
// See https://trac.ffmpeg.org/ticket/5456
|
||||||
video.duration = await getDurationFromVideoFile(videoTranscodedPath)
|
video.duration = await getVideoStreamDuration(videoTranscodedPath)
|
||||||
await video.save()
|
await video.save()
|
||||||
|
|
||||||
return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
|
return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
|
||||||
|
@ -239,8 +246,8 @@ async function onWebTorrentVideoFileTranscoding (
|
||||||
outputPath: string
|
outputPath: string
|
||||||
) {
|
) {
|
||||||
const stats = await stat(transcodingPath)
|
const stats = await stat(transcodingPath)
|
||||||
const fps = await getVideoFileFPS(transcodingPath)
|
const fps = await getVideoStreamFPS(transcodingPath)
|
||||||
const metadata = await getMetadataFromFile(transcodingPath)
|
const metadata = await buildFileMetadata(transcodingPath)
|
||||||
|
|
||||||
await move(transcodingPath, outputPath, { overwrite: true })
|
await move(transcodingPath, outputPath, { overwrite: true })
|
||||||
|
|
||||||
|
@ -299,7 +306,7 @@ async function generateHlsPlaylistCommon (options: {
|
||||||
job
|
job
|
||||||
}
|
}
|
||||||
|
|
||||||
await transcode(transcodeOptions)
|
await transcodeVOD(transcodeOptions)
|
||||||
|
|
||||||
// Create or update the playlist
|
// Create or update the playlist
|
||||||
const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
|
const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
|
||||||
|
@ -344,8 +351,8 @@ async function generateHlsPlaylistCommon (options: {
|
||||||
const stats = await stat(videoFilePath)
|
const stats = await stat(videoFilePath)
|
||||||
|
|
||||||
newVideoFile.size = stats.size
|
newVideoFile.size = stats.size
|
||||||
newVideoFile.fps = await getVideoFileFPS(videoFilePath)
|
newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
|
||||||
newVideoFile.metadata = await getMetadataFromFile(videoFilePath)
|
newVideoFile.metadata = await buildFileMetadata(videoFilePath)
|
||||||
|
|
||||||
await createTorrentAndSetInfoHash(playlist, newVideoFile)
|
await createTorrentAndSetInfoHash(playlist, newVideoFile)
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { buildActorInstance } from './local-actor'
|
||||||
import { Redis } from './redis'
|
import { Redis } from './redis'
|
||||||
import { createLocalVideoChannel } from './video-channel'
|
import { createLocalVideoChannel } from './video-channel'
|
||||||
import { createWatchLaterPlaylist } from './video-playlist'
|
import { createWatchLaterPlaylist } from './video-playlist'
|
||||||
|
import { logger } from '@server/helpers/logger'
|
||||||
|
|
||||||
type ChannelNames = { name: string, displayName: string }
|
type ChannelNames = { name: string, displayName: string }
|
||||||
|
|
||||||
|
@ -159,6 +160,11 @@ async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
|
||||||
const uploadedTotal = newVideoSize + totalBytes
|
const uploadedTotal = newVideoSize + totalBytes
|
||||||
const uploadedDaily = newVideoSize + totalBytesDaily
|
const uploadedDaily = newVideoSize + totalBytesDaily
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Check user %d quota to upload another video.', userId,
|
||||||
|
{ totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, newVideoSize }
|
||||||
|
)
|
||||||
|
|
||||||
if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
|
if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
|
||||||
if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
|
if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
|
||||||
|
|
||||||
|
|
32
server/lib/video-editor.ts
Normal file
32
server/lib/video-editor.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { MVideoFullLight } from "@server/types/models"
|
||||||
|
import { getVideoStreamDuration } from "@shared/extra-utils"
|
||||||
|
import { VideoEditorTask } from "@shared/models"
|
||||||
|
|
||||||
|
function buildTaskFileFieldname (indice: number, fieldName = 'file') {
|
||||||
|
return `tasks[${indice}][options][${fieldName}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') {
|
||||||
|
return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoEditorTask[], fileFinder: (i: number) => string) {
|
||||||
|
let additionalDuration = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < tasks.length; i++) {
|
||||||
|
const task = tasks[i]
|
||||||
|
|
||||||
|
if (task.name !== 'add-intro' && task.name !== 'add-outro') continue
|
||||||
|
|
||||||
|
const filePath = fileFinder(i)
|
||||||
|
additionalDuration += await getVideoStreamDuration(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (video.getMaxQualityFile().size / video.duration) * additionalDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
approximateIntroOutroAdditionalSize,
|
||||||
|
buildTaskFileFieldname,
|
||||||
|
getTaskFile
|
||||||
|
}
|
|
@ -81,7 +81,7 @@ async function setVideoTags (options: {
|
||||||
video.Tags = tagInstances
|
video.Tags = tagInstances
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId) {
|
async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId, isNewVideo = true) {
|
||||||
let dataInput: VideoTranscodingPayload
|
let dataInput: VideoTranscodingPayload
|
||||||
|
|
||||||
if (videoFile.isAudio()) {
|
if (videoFile.isAudio()) {
|
||||||
|
@ -90,13 +90,13 @@ async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoF
|
||||||
resolution: DEFAULT_AUDIO_RESOLUTION,
|
resolution: DEFAULT_AUDIO_RESOLUTION,
|
||||||
videoUUID: video.uuid,
|
videoUUID: video.uuid,
|
||||||
createHLSIfNeeded: true,
|
createHLSIfNeeded: true,
|
||||||
isNewVideo: true
|
isNewVideo
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
dataInput = {
|
dataInput = {
|
||||||
type: 'optimize-to-webtorrent',
|
type: 'optimize-to-webtorrent',
|
||||||
videoUUID: video.uuid,
|
videoUUID: video.uuid,
|
||||||
isNewVideo: true
|
isNewVideo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,8 @@ const customConfigUpdateValidator = [
|
||||||
body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
|
body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
|
||||||
body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
|
body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
|
||||||
|
|
||||||
|
body('videoEditor.enabled').isBoolean().withMessage('Should have a valid video editor enabled boolean'),
|
||||||
|
|
||||||
body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'),
|
body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'),
|
||||||
body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
|
body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
|
||||||
body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
|
body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
|
||||||
|
@ -104,6 +106,7 @@ const customConfigUpdateValidator = [
|
||||||
if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
|
if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
|
||||||
if (!checkInvalidTranscodingConfig(req.body, res)) return
|
if (!checkInvalidTranscodingConfig(req.body, res)) return
|
||||||
if (!checkInvalidLiveConfig(req.body, res)) return
|
if (!checkInvalidLiveConfig(req.body, res)) return
|
||||||
|
if (!checkInvalidVideoEditorConfig(req.body, res)) return
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
@ -159,3 +162,14 @@ function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Respon
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkInvalidVideoEditorConfig (customConfig: CustomConfig, res: express.Response) {
|
||||||
|
if (customConfig.videoEditor.enabled === false) return true
|
||||||
|
|
||||||
|
if (customConfig.videoEditor.enabled === true && customConfig.transcoding.enabled === false) {
|
||||||
|
res.fail({ message: 'You cannot enable video editor if transcoding is not enabled' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ function areValidationErrors (req: express.Request, res: express.Response) {
|
||||||
|
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
|
logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
|
||||||
|
|
||||||
res.fail({
|
res.fail({
|
||||||
message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
|
message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
|
||||||
instance: req.originalUrl,
|
instance: req.originalUrl,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Request, Response } from 'express'
|
import { Request, Response } from 'express'
|
||||||
import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
|
import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
|
||||||
|
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||||
import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
|
import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
|
||||||
import { VideoModel } from '@server/models/video/video'
|
import { VideoModel } from '@server/models/video/video'
|
||||||
import { VideoChannelModel } from '@server/models/video/video-channel'
|
import { VideoChannelModel } from '@server/models/video/video-channel'
|
||||||
|
@ -7,6 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
import {
|
import {
|
||||||
MUser,
|
MUser,
|
||||||
MUserAccountId,
|
MUserAccountId,
|
||||||
|
MUserId,
|
||||||
MVideo,
|
MVideo,
|
||||||
MVideoAccountLight,
|
MVideoAccountLight,
|
||||||
MVideoFormattableDetails,
|
MVideoFormattableDetails,
|
||||||
|
@ -16,7 +18,7 @@ import {
|
||||||
MVideoThumbnail,
|
MVideoThumbnail,
|
||||||
MVideoWithRights
|
MVideoWithRights
|
||||||
} from '@server/types/models'
|
} from '@server/types/models'
|
||||||
import { HttpStatusCode, UserRight } from '@shared/models'
|
import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models'
|
||||||
|
|
||||||
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
|
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
|
||||||
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
|
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
|
||||||
|
@ -108,6 +110,11 @@ async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVid
|
||||||
|
|
||||||
// Only the owner or a user that have blocklist rights can see the video
|
// Only the owner or a user that have blocklist rights can see the video
|
||||||
if (!user || !user.canGetVideo(video)) {
|
if (!user || !user.canGetVideo(video)) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.FORBIDDEN_403,
|
||||||
|
message: 'Cannot fetch information of private/internal/blocklisted video'
|
||||||
|
})
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,13 +146,28 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) {
|
||||||
|
if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
||||||
|
message: 'The user video quota is exceeded with this video.',
|
||||||
|
type: ServerErrorCode.QUOTA_REACHED
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
doesVideoChannelOfAccountExist,
|
doesVideoChannelOfAccountExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
doesVideoFileOfVideoExist,
|
doesVideoFileOfVideoExist,
|
||||||
|
|
||||||
checkUserCanManageVideo,
|
checkUserCanManageVideo,
|
||||||
checkCanSeeVideoIfPrivate,
|
checkCanSeeVideoIfPrivate,
|
||||||
checkCanSeePrivateVideo
|
checkCanSeePrivateVideo,
|
||||||
|
checkUserQuota
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ export * from './video-blacklist'
|
||||||
export * from './video-captions'
|
export * from './video-captions'
|
||||||
export * from './video-channels'
|
export * from './video-channels'
|
||||||
export * from './video-comments'
|
export * from './video-comments'
|
||||||
|
export * from './video-editor'
|
||||||
export * from './video-files'
|
export * from './video-files'
|
||||||
export * from './video-imports'
|
export * from './video-imports'
|
||||||
export * from './video-live'
|
export * from './video-live'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { body, param } from 'express-validator'
|
import { body, param } from 'express-validator'
|
||||||
import { HttpStatusCode, UserRight } from '@shared/models'
|
import { UserRight } from '@shared/models'
|
||||||
import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
|
import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
|
||||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
|
@ -74,13 +74,7 @@ const listVideoCaptionsValidator = [
|
||||||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||||
|
|
||||||
const video = res.locals.onlyVideo
|
const video = res.locals.onlyVideo
|
||||||
|
if (!await checkCanSeeVideoIfPrivate(req, res, video)) return
|
||||||
if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
|
|
||||||
return res.fail({
|
|
||||||
status: HttpStatusCode.FORBIDDEN_403,
|
|
||||||
message: 'Cannot list captions of private/internal/blocklisted video'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,12 +54,7 @@ const listVideoCommentThreadsValidator = [
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||||
|
|
||||||
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) {
|
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
|
||||||
return res.fail({
|
|
||||||
status: HttpStatusCode.FORBIDDEN_403,
|
|
||||||
message: 'Cannot list comments of private/internal/blocklisted video'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
@ -78,12 +73,7 @@ const listVideoThreadCommentsValidator = [
|
||||||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||||
if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
|
if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
|
||||||
|
|
||||||
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) {
|
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
|
||||||
return res.fail({
|
|
||||||
status: HttpStatusCode.FORBIDDEN_403,
|
|
||||||
message: 'Cannot list threads of private/internal/blocklisted video'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
112
server/middlewares/validators/videos/video-editor.ts
Normal file
112
server/middlewares/validators/videos/video-editor.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { body, param } from 'express-validator'
|
||||||
|
import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc'
|
||||||
|
import {
|
||||||
|
isEditorCutTaskValid,
|
||||||
|
isEditorTaskAddIntroOutroValid,
|
||||||
|
isEditorTaskAddWatermarkValid,
|
||||||
|
isValidEditorTasksArray
|
||||||
|
} from '@server/helpers/custom-validators/video-editor'
|
||||||
|
import { cleanUpReqFiles } from '@server/helpers/express-utils'
|
||||||
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-editor'
|
||||||
|
import { isAudioFile } from '@shared/extra-utils'
|
||||||
|
import { HttpStatusCode, UserRight, VideoEditorCreateEdition, VideoEditorTask, VideoState } from '@shared/models'
|
||||||
|
import { logger } from '../../../helpers/logger'
|
||||||
|
import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
|
||||||
|
|
||||||
|
const videosEditorAddEditionValidator = [
|
||||||
|
param('videoId').custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
|
||||||
|
|
||||||
|
body('tasks').custom(isValidEditorTasksArray).withMessage('Should have a valid array of tasks'),
|
||||||
|
|
||||||
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
logger.debug('Checking videosEditorAddEditionValidator parameters.', { parameters: req.params, body: req.body, files: req.files })
|
||||||
|
|
||||||
|
if (CONFIG.VIDEO_EDITOR.ENABLED !== true) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: 'Video editor is disabled on this instance'
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanUpReqFiles(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
const body: VideoEditorCreateEdition = req.body
|
||||||
|
const files = req.files as Express.Multer.File[]
|
||||||
|
|
||||||
|
for (let i = 0; i < body.tasks.length; i++) {
|
||||||
|
const task = body.tasks[i]
|
||||||
|
|
||||||
|
if (!checkTask(req, task, i)) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: `Task ${task.name} is invalid`
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanUpReqFiles(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.name === 'add-intro' || task.name === 'add-outro') {
|
||||||
|
const filePath = getTaskFile(files, i).path
|
||||||
|
|
||||||
|
// Our concat filter needs a video stream
|
||||||
|
if (await isAudioFile(filePath)) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.BAD_REQUEST_400,
|
||||||
|
message: `Task ${task.name} is invalid: file does not contain a video stream`
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanUpReqFiles(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
const video = res.locals.videoAll
|
||||||
|
if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
|
||||||
|
res.fail({
|
||||||
|
status: HttpStatusCode.CONFLICT_409,
|
||||||
|
message: 'Cannot edit video that is already waiting for transcoding/edition'
|
||||||
|
})
|
||||||
|
|
||||||
|
return cleanUpReqFiles(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = res.locals.oauth.token.User
|
||||||
|
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
// Try to make an approximation of bytes added by the intro/outro
|
||||||
|
const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFile(files, i).path)
|
||||||
|
if (await checkUserQuota(user, additionalBytes, res) === false) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
videosEditorAddEditionValidator
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const taskCheckers: {
|
||||||
|
[id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => boolean
|
||||||
|
} = {
|
||||||
|
'cut': isEditorCutTaskValid,
|
||||||
|
'add-intro': isEditorTaskAddIntroOutroValid,
|
||||||
|
'add-outro': isEditorTaskAddIntroOutroValid,
|
||||||
|
'add-watermark': isEditorTaskAddWatermarkValid
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTask (req: express.Request, task: VideoEditorTask, indice?: number) {
|
||||||
|
const checker = taskCheckers[task.name]
|
||||||
|
if (!checker) return false
|
||||||
|
|
||||||
|
return checker(task, indice, req.files as Express.Multer.File[])
|
||||||
|
}
|
|
@ -3,20 +3,13 @@ import { param } from 'express-validator'
|
||||||
import { isIdValid } from '@server/helpers/custom-validators/misc'
|
import { isIdValid } from '@server/helpers/custom-validators/misc'
|
||||||
import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
|
import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
|
||||||
import { AccountModel } from '@server/models/account/account'
|
import { AccountModel } from '@server/models/account/account'
|
||||||
import { MVideoWithAllFiles } from '@server/types/models'
|
import { MVideoWithAllFiles } from '@server/types/models'
|
||||||
import {
|
import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models'
|
||||||
HttpStatusCode,
|
|
||||||
ServerErrorCode,
|
|
||||||
UserRight,
|
|
||||||
VideoChangeOwnershipAccept,
|
|
||||||
VideoChangeOwnershipStatus,
|
|
||||||
VideoState
|
|
||||||
} from '@shared/models'
|
|
||||||
import {
|
import {
|
||||||
areValidationErrors,
|
areValidationErrors,
|
||||||
checkUserCanManageVideo,
|
checkUserCanManageVideo,
|
||||||
|
checkUserQuota,
|
||||||
doesChangeVideoOwnershipExist,
|
doesChangeVideoOwnershipExist,
|
||||||
doesVideoChannelOfAccountExist,
|
doesVideoChannelOfAccountExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
|
@ -113,15 +106,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response)
|
||||||
|
|
||||||
const user = res.locals.oauth.token.User
|
const user = res.locals.oauth.token.User
|
||||||
|
|
||||||
if (!await isAbleToUploadVideo(user.id, video.getMaxQualityFile().size)) {
|
if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
|
||||||
message: 'The user video quota is exceeded with this video.',
|
|
||||||
type: ServerErrorCode.QUOTA_REACHED
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ import {
|
||||||
isVideoPlaylistTimestampValid,
|
isVideoPlaylistTimestampValid,
|
||||||
isVideoPlaylistTypeValid
|
isVideoPlaylistTypeValid
|
||||||
} from '../../../helpers/custom-validators/video-playlists'
|
} from '../../../helpers/custom-validators/video-playlists'
|
||||||
import { isVideoImage } from '../../../helpers/custom-validators/videos'
|
import { isVideoImageValid } from '../../../helpers/custom-validators/videos'
|
||||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
|
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
|
||||||
|
@ -390,7 +390,7 @@ export {
|
||||||
function getCommonPlaylistEditAttributes () {
|
function getCommonPlaylistEditAttributes () {
|
||||||
return [
|
return [
|
||||||
body('thumbnailfile')
|
body('thumbnailfile')
|
||||||
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile'))
|
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
|
||||||
.withMessage(
|
.withMessage(
|
||||||
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
|
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
|
||||||
CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
|
CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { body, header, param, query, ValidationChain } from 'express-validator'
|
||||||
import { isTestInstance } from '@server/helpers/core-utils'
|
import { isTestInstance } from '@server/helpers/core-utils'
|
||||||
import { getResumableUploadPath } from '@server/helpers/upload'
|
import { getResumableUploadPath } from '@server/helpers/upload'
|
||||||
import { Redis } from '@server/lib/redis'
|
import { Redis } from '@server/lib/redis'
|
||||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
|
||||||
import { getServerActor } from '@server/models/application/application'
|
import { getServerActor } from '@server/models/application/application'
|
||||||
import { ExpressPromiseHandler } from '@server/types/express-handler'
|
import { ExpressPromiseHandler } from '@server/types/express-handler'
|
||||||
import { MUserAccountId, MVideoFullLight } from '@server/types/models'
|
import { MUserAccountId, MVideoFullLight } from '@server/types/models'
|
||||||
|
@ -13,7 +12,7 @@ import {
|
||||||
exists,
|
exists,
|
||||||
isBooleanValid,
|
isBooleanValid,
|
||||||
isDateValid,
|
isDateValid,
|
||||||
isFileFieldValid,
|
isFileValid,
|
||||||
isIdValid,
|
isIdValid,
|
||||||
isUUIDValid,
|
isUUIDValid,
|
||||||
toArray,
|
toArray,
|
||||||
|
@ -23,24 +22,24 @@ import {
|
||||||
} from '../../../helpers/custom-validators/misc'
|
} from '../../../helpers/custom-validators/misc'
|
||||||
import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
|
import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
|
||||||
import {
|
import {
|
||||||
|
areVideoTagsValid,
|
||||||
isScheduleVideoUpdatePrivacyValid,
|
isScheduleVideoUpdatePrivacyValid,
|
||||||
isVideoCategoryValid,
|
isVideoCategoryValid,
|
||||||
isVideoDescriptionValid,
|
isVideoDescriptionValid,
|
||||||
isVideoFileMimeTypeValid,
|
isVideoFileMimeTypeValid,
|
||||||
isVideoFileSizeValid,
|
isVideoFileSizeValid,
|
||||||
isVideoFilterValid,
|
isVideoFilterValid,
|
||||||
isVideoImage,
|
isVideoImageValid,
|
||||||
isVideoIncludeValid,
|
isVideoIncludeValid,
|
||||||
isVideoLanguageValid,
|
isVideoLanguageValid,
|
||||||
isVideoLicenceValid,
|
isVideoLicenceValid,
|
||||||
isVideoNameValid,
|
isVideoNameValid,
|
||||||
isVideoOriginallyPublishedAtValid,
|
isVideoOriginallyPublishedAtValid,
|
||||||
isVideoPrivacyValid,
|
isVideoPrivacyValid,
|
||||||
isVideoSupportValid,
|
isVideoSupportValid
|
||||||
isVideoTagsValid
|
|
||||||
} from '../../../helpers/custom-validators/videos'
|
} from '../../../helpers/custom-validators/videos'
|
||||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||||
import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
|
import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
|
||||||
import { logger } from '../../../helpers/logger'
|
import { logger } from '../../../helpers/logger'
|
||||||
import { deleteFileAndCatch } from '../../../helpers/utils'
|
import { deleteFileAndCatch } from '../../../helpers/utils'
|
||||||
import { getVideoWithAttributes } from '../../../helpers/video'
|
import { getVideoWithAttributes } from '../../../helpers/video'
|
||||||
|
@ -53,6 +52,7 @@ import {
|
||||||
areValidationErrors,
|
areValidationErrors,
|
||||||
checkCanSeePrivateVideo,
|
checkCanSeePrivateVideo,
|
||||||
checkUserCanManageVideo,
|
checkUserCanManageVideo,
|
||||||
|
checkUserQuota,
|
||||||
doesVideoChannelOfAccountExist,
|
doesVideoChannelOfAccountExist,
|
||||||
doesVideoExist,
|
doesVideoExist,
|
||||||
doesVideoFileOfVideoExist,
|
doesVideoFileOfVideoExist,
|
||||||
|
@ -61,7 +61,7 @@ import {
|
||||||
|
|
||||||
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||||
body('videofile')
|
body('videofile')
|
||||||
.custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
|
.custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
|
||||||
.withMessage('Should have a file'),
|
.withMessage('Should have a file'),
|
||||||
body('name')
|
body('name')
|
||||||
.trim()
|
.trim()
|
||||||
|
@ -299,12 +299,11 @@ const videosCustomGetValidator = (
|
||||||
|
|
||||||
// Video private or blacklisted
|
// Video private or blacklisted
|
||||||
if (video.requiresAuth()) {
|
if (video.requiresAuth()) {
|
||||||
if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next()
|
if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
return res.fail({
|
return
|
||||||
status: HttpStatusCode.FORBIDDEN_403,
|
|
||||||
message: 'Cannot get this private/internal or blocklisted video'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video is public, anyone can access it
|
// Video is public, anyone can access it
|
||||||
|
@ -375,12 +374,12 @@ const videosOverviewValidator = [
|
||||||
function getCommonVideoEditAttributes () {
|
function getCommonVideoEditAttributes () {
|
||||||
return [
|
return [
|
||||||
body('thumbnailfile')
|
body('thumbnailfile')
|
||||||
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
|
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
|
||||||
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
|
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
|
||||||
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
|
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
|
||||||
),
|
),
|
||||||
body('previewfile')
|
body('previewfile')
|
||||||
.custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
|
.custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
|
||||||
'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
|
'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
|
||||||
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
|
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
|
||||||
),
|
),
|
||||||
|
@ -420,7 +419,7 @@ function getCommonVideoEditAttributes () {
|
||||||
body('tags')
|
body('tags')
|
||||||
.optional()
|
.optional()
|
||||||
.customSanitizer(toValueOrNull)
|
.customSanitizer(toValueOrNull)
|
||||||
.custom(isVideoTagsValid)
|
.custom(areVideoTagsValid)
|
||||||
.withMessage(
|
.withMessage(
|
||||||
`Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
|
`Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
|
||||||
`${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
|
`${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
|
||||||
|
@ -612,14 +611,7 @@ async function commonVideoChecksPass (parameters: {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
|
if (await checkUserQuota(user, videoFileSize, res) === false) return false
|
||||||
res.fail({
|
|
||||||
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
|
||||||
message: 'The user video quota is exceeded with this video.',
|
|
||||||
type: ServerErrorCode.QUOTA_REACHED
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -654,7 +646,7 @@ export async function isVideoAccepted (
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
|
async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
|
||||||
const duration: number = await getDurationFromVideoFile(videoFile.path)
|
const duration: number = await getVideoStreamDuration(videoFile.path)
|
||||||
|
|
||||||
if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
|
if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ import {
|
||||||
isVideoStateValid,
|
isVideoStateValid,
|
||||||
isVideoSupportValid
|
isVideoSupportValid
|
||||||
} from '../../helpers/custom-validators/videos'
|
} from '../../helpers/custom-validators/videos'
|
||||||
import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
|
import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
|
||||||
import { logger } from '../../helpers/logger'
|
import { logger } from '../../helpers/logger'
|
||||||
import { CONFIG } from '../../initializers/config'
|
import { CONFIG } from '../../initializers/config'
|
||||||
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
|
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
|
||||||
|
@ -1683,7 +1683,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
return peertubeTruncate(this.description, { length: maxLength })
|
return peertubeTruncate(this.description, { length: maxLength })
|
||||||
}
|
}
|
||||||
|
|
||||||
getMaxQualityFileInfo () {
|
probeMaxQualityFile () {
|
||||||
const file = this.getMaxQualityFile()
|
const file = this.getMaxQualityFile()
|
||||||
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
|
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
|
||||||
|
|
||||||
|
@ -1695,7 +1695,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
return {
|
return {
|
||||||
audioStream,
|
audioStream,
|
||||||
|
|
||||||
...await getVideoFileResolution(originalFilePath, probe)
|
...await getVideoStreamDimensionsInfo(originalFilePath, probe)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,12 +25,16 @@ describe('Test AP refresher', function () {
|
||||||
before(async function () {
|
before(async function () {
|
||||||
this.timeout(60000)
|
this.timeout(60000)
|
||||||
|
|
||||||
servers = await createMultipleServers(2, { transcoding: { enabled: false } })
|
servers = await createMultipleServers(2)
|
||||||
|
|
||||||
// Get the access tokens
|
// Get the access tokens
|
||||||
await setAccessTokensToServers(servers)
|
await setAccessTokensToServers(servers)
|
||||||
await setDefaultVideoChannel(servers)
|
await setDefaultVideoChannel(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await server.config.disableTranscoding()
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid
|
videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid
|
||||||
videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid
|
videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid
|
||||||
|
|
|
@ -145,6 +145,9 @@ describe('Test config API validators', function () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
videoEditor: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
videos: {
|
videos: {
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
|
|
@ -25,6 +25,7 @@ import './video-blacklist'
|
||||||
import './video-captions'
|
import './video-captions'
|
||||||
import './video-channels'
|
import './video-channels'
|
||||||
import './video-comments'
|
import './video-comments'
|
||||||
|
import './video-editor'
|
||||||
import './video-imports'
|
import './video-imports'
|
||||||
import './video-playlists'
|
import './video-playlists'
|
||||||
import './videos'
|
import './videos'
|
||||||
|
|
385
server/tests/api/check-params/video-editor.ts
Normal file
385
server/tests/api/check-params/video-editor.ts
Normal file
|
@ -0,0 +1,385 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import 'mocha'
|
||||||
|
import { HttpStatusCode, VideoEditorTask } from '@shared/models'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createSingleServer,
|
||||||
|
PeerTubeServer,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
VideoEditorCommand,
|
||||||
|
waitJobs
|
||||||
|
} from '@shared/server-commands'
|
||||||
|
|
||||||
|
describe('Test video editor API validator', function () {
|
||||||
|
let server: PeerTubeServer
|
||||||
|
let command: VideoEditorCommand
|
||||||
|
let userAccessToken: string
|
||||||
|
let videoUUID: string
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120_000)
|
||||||
|
|
||||||
|
server = await createSingleServer(1)
|
||||||
|
|
||||||
|
await setAccessTokensToServers([ server ])
|
||||||
|
userAccessToken = await server.users.generateUserAndToken('user1')
|
||||||
|
|
||||||
|
await server.config.enableMinimumTranscoding()
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({ name: 'video' })
|
||||||
|
videoUUID = uuid
|
||||||
|
|
||||||
|
command = server.videoEditor
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Task creation', function () {
|
||||||
|
|
||||||
|
describe('Config settings', function () {
|
||||||
|
|
||||||
|
it('Should fail if editor is disabled', async function () {
|
||||||
|
await server.config.updateExistingSubConfig({
|
||||||
|
newConfig: {
|
||||||
|
videoEditor: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail to enable editor if transcoding is disabled', async function () {
|
||||||
|
await server.config.updateExistingSubConfig({
|
||||||
|
newConfig: {
|
||||||
|
videoEditor: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
transcoding: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed to enable video editor', async function () {
|
||||||
|
await server.config.updateExistingSubConfig({
|
||||||
|
newConfig: {
|
||||||
|
videoEditor: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
transcoding: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Common tasks', function () {
|
||||||
|
|
||||||
|
it('Should fail without token', async function () {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
token: null,
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with another user token', async function () {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
token: userAccessToken,
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid video', async function () {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: 'tintin',
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an unknown video', async function () {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: 42,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.NOT_FOUND_404
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an already in transcoding state video', async function () {
|
||||||
|
await server.jobs.pauseJobQueue()
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({ name: 'transcoded video' })
|
||||||
|
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: uuid,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.CONFLICT_409
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.jobs.resumeJobQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a bad complex task', async function () {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
name: 'cut',
|
||||||
|
options: {
|
||||||
|
start: 1,
|
||||||
|
end: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hadock',
|
||||||
|
options: {
|
||||||
|
start: 1,
|
||||||
|
end: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
] as any,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without task', async function () {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: [],
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with too many tasks', async function () {
|
||||||
|
const tasks: VideoEditorTask[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < 110; i++) {
|
||||||
|
tasks.push({
|
||||||
|
name: 'cut',
|
||||||
|
options: {
|
||||||
|
start: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with correct parameters', async function () {
|
||||||
|
await server.jobs.pauseJobQueue()
|
||||||
|
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a video that is already waiting for edition', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: VideoEditorCommand.getComplexTask(),
|
||||||
|
expectedStatus: HttpStatusCode.CONFLICT_409
|
||||||
|
})
|
||||||
|
|
||||||
|
await server.jobs.resumeJobQueue()
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cut task', function () {
|
||||||
|
|
||||||
|
async function cut (start: number, end: number, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
name: 'cut',
|
||||||
|
options: {
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should fail with bad start/end', async function () {
|
||||||
|
const invalid = [
|
||||||
|
'tintin',
|
||||||
|
-1,
|
||||||
|
undefined
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const value of invalid) {
|
||||||
|
await cut(value as any, undefined)
|
||||||
|
await cut(undefined, value as any)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with the same start/end', async function () {
|
||||||
|
await cut(2, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with inconsistents start/end', async function () {
|
||||||
|
await cut(2, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without start and end', async function () {
|
||||||
|
await cut(undefined, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct params', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await cut(0, 2, HttpStatusCode.NO_CONTENT_204)
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Watermark task', function () {
|
||||||
|
|
||||||
|
async function addWatermark (file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
name: 'add-watermark',
|
||||||
|
options: {
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should fail without waterkmark', async function () {
|
||||||
|
await addWatermark(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid watermark', async function () {
|
||||||
|
await addWatermark('video_short.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct params', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await addWatermark('thumbnail.jpg', HttpStatusCode.NO_CONTENT_204)
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Intro/Outro task', function () {
|
||||||
|
|
||||||
|
async function addIntroOutro (type: 'add-intro' | 'add-outro', file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
|
||||||
|
await command.createEditionTasks({
|
||||||
|
videoId: videoUUID,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
name: type,
|
||||||
|
options: {
|
||||||
|
file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should fail without file', async function () {
|
||||||
|
await addIntroOutro('add-intro', undefined)
|
||||||
|
await addIntroOutro('add-outro', undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid file', async function () {
|
||||||
|
await addIntroOutro('add-intro', 'thumbnail.jpg')
|
||||||
|
await addIntroOutro('add-outro', 'thumbnail.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with a file that does not contain video stream', async function () {
|
||||||
|
await addIntroOutro('add-intro', 'sample.ogg')
|
||||||
|
await addIntroOutro('add-outro', 'sample.ogg')
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct params', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
await addIntroOutro('add-intro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
await addIntroOutro('add-outro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
|
||||||
|
await waitJobs([ server ])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should check total quota when creating the task', async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const user = await server.users.create({ username: 'user_quota_1' })
|
||||||
|
const token = await server.login.getAccessToken('user_quota_1')
|
||||||
|
const { uuid } = await server.videos.quickUpload({ token, name: 'video_quota_1', fixture: 'video_short.mp4' })
|
||||||
|
|
||||||
|
const addIntroOutroByUser = (type: 'add-intro' | 'add-outro', expectedStatus: HttpStatusCode) => {
|
||||||
|
return command.createEditionTasks({
|
||||||
|
token,
|
||||||
|
videoId: uuid,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
name: type,
|
||||||
|
options: {
|
||||||
|
file: 'video_short.mp4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
expectedStatus
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
const { videoQuotaUsed } = await server.users.getMyQuotaUsed({ token })
|
||||||
|
await server.users.update({ userId: user.id, videoQuota: Math.round(videoQuotaUsed * 2.5) })
|
||||||
|
|
||||||
|
// Still valid
|
||||||
|
await addIntroOutroByUser('add-intro', HttpStatusCode.NO_CONTENT_204)
|
||||||
|
|
||||||
|
await waitJobs([ server ])
|
||||||
|
|
||||||
|
// Too much quota
|
||||||
|
await addIntroOutroByUser('add-intro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
|
||||||
|
await addIntroOutroByUser('add-outro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -3,7 +3,7 @@
|
||||||
import 'mocha'
|
import 'mocha'
|
||||||
import * as chai from 'chai'
|
import * as chai from 'chai'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils'
|
import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
|
||||||
import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
|
import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
|
||||||
import { wait } from '@shared/core-utils'
|
import { wait } from '@shared/core-utils'
|
||||||
import {
|
import {
|
||||||
|
@ -562,7 +562,7 @@ describe('Test live', function () {
|
||||||
const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
|
const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
|
||||||
|
|
||||||
const probe = await ffprobePromise(segmentPath)
|
const probe = await ffprobePromise(segmentPath)
|
||||||
const videoStream = await getVideoStreamFromFile(segmentPath, probe)
|
const videoStream = await getVideoStream(segmentPath, probe)
|
||||||
|
|
||||||
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
|
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
|
||||||
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height])
|
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height])
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user