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: {}
|
||||
}
|
||||
},
|
||||
videoEditor: {
|
||||
enabled: null
|
||||
},
|
||||
autoBlacklist: {
|
||||
videos: {
|
||||
ofUsers: {
|
||||
|
|
|
@ -192,4 +192,29 @@
|
|||
|
||||
</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>
|
||||
|
|
|
@ -71,6 +71,8 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
|||
}
|
||||
|
||||
private checkTranscodingFields () {
|
||||
const transcodingControl = this.form.get('transcoding.enabled')
|
||||
const videoEditorControl = this.form.get('videoEditor.enabled')
|
||||
const hlsControl = this.form.get('transcoding.hls.enabled')
|
||||
const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
|
||||
|
||||
|
@ -95,5 +97,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
|||
webtorrentControl.enable()
|
||||
}
|
||||
})
|
||||
|
||||
transcodingControl.valueChanges
|
||||
.subscribe(newValue => {
|
||||
if (newValue === false) {
|
||||
videoEditorControl.setValue(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
my-embed {
|
||||
display: block;
|
||||
max-width: 500px;
|
||||
|
|
|
@ -9,7 +9,7 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms'
|
|||
import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
|
||||
import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
|
||||
import { MiniatureDisplayOptions, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
|
||||
import { VideoChannel, VideoSortField } from '@shared/models'
|
||||
import { VideoChannel, VideoSortField, VideoState } from '@shared/models'
|
||||
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
|
||||
|
||||
@Component({
|
||||
|
@ -204,6 +204,12 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
|
|||
|
||||
private buildActions () {
|
||||
this.videoActions = [
|
||||
{
|
||||
label: $localize`Editor`,
|
||||
linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
|
||||
isDisplayed: ({ video }) => video.state.id === VideoState.PUBLISHED,
|
||||
iconName: 'film'
|
||||
},
|
||||
{
|
||||
label: $localize`Display live information`,
|
||||
handler: ({ video }) => this.displayLiveInformation(video),
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './video-editor.module'
|
|
@ -0,0 +1 @@
|
|||
export * from './video-editor.service'
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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,
|
||||
download: true,
|
||||
update: true,
|
||||
editor: true,
|
||||
blacklist: true,
|
||||
delete: true,
|
||||
report: true,
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
The video is being transcoded, it may not work properly.
|
||||
</div>
|
||||
|
||||
<div i18n class="alert alert-warning" *ngIf="isVideoToEdit()">
|
||||
The video is being edited, it may not work properly.
|
||||
</div>
|
||||
|
||||
<div i18n class="alert alert-warning" *ngIf="isVideoToMoveToExternalStorage()">
|
||||
The video is being moved to an external server, it may not work properly.
|
||||
</div>
|
||||
|
|
|
@ -14,6 +14,10 @@ export class VideoAlertComponent {
|
|||
return this.video && this.video.state.id === VideoState.TO_TRANSCODE
|
||||
}
|
||||
|
||||
isVideoToEdit () {
|
||||
return this.video && this.video.state.id === VideoState.TO_EDIT
|
||||
}
|
||||
|
||||
isVideoTranscodingFailed () {
|
||||
return this.video && this.video.state.id === VideoState.TRANSCODING_FAILED
|
||||
}
|
||||
|
|
|
@ -143,6 +143,12 @@ const routes: Routes = [
|
|||
canActivateChild: [ MetaGuard ]
|
||||
},
|
||||
|
||||
{
|
||||
path: 'video-editor',
|
||||
loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
|
||||
canActivateChild: [ MetaGuard ]
|
||||
},
|
||||
|
||||
// Matches /@:actorName
|
||||
{
|
||||
matcher: (url): UrlMatchResult => {
|
||||
|
|
|
@ -24,7 +24,7 @@ export abstract class FormReactive {
|
|||
this.formErrors = formErrors
|
||||
this.validationMessages = validationMessages
|
||||
|
||||
this.form.statusChanges.subscribe(async status => {
|
||||
this.form.statusChanges.subscribe(async () => {
|
||||
// FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
|
||||
await this.waitPendingCheck()
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ export class FormValidatorService {
|
|||
|
||||
if (field?.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
|
||||
|
||||
const defaultValue = defaultValues[name] || ''
|
||||
const defaultValue = defaultValues[name] ?? ''
|
||||
|
||||
if (field?.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
|
||||
else group[name] = [ defaultValue ]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<p-inputMask
|
||||
[disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
|
||||
mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
|
||||
[ngClass]="{ 'border-disabled': disableBorder }"
|
||||
mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" [inputId]="inputName"
|
||||
></p-inputMask>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
p-inputmask {
|
||||
::ng-deep input {
|
||||
width: 80px;
|
||||
font-size: 15px;
|
||||
border: 0;
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
|
@ -16,4 +16,16 @@ p-inputmask {
|
|||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.border-disabled {
|
||||
::ng-deep input {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.border-disabled) {
|
||||
::ng-deep input {
|
||||
@include peertube-input-text(80px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ export class TimestampInputComponent implements ControlValueAccessor, OnInit {
|
|||
@Input() maxTimestamp: number
|
||||
@Input() timestamp: number
|
||||
@Input() disabled = false
|
||||
@Input() inputName: string
|
||||
@Input() disableBorder = true
|
||||
|
||||
@Output() inputBlur = new EventEmitter()
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
|
||||
import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core'
|
||||
import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
|
||||
import { BlocklistService, VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { VideoCaption } from '@shared/models'
|
||||
import { VideoCaption, VideoState } from '@shared/models'
|
||||
import {
|
||||
Actor,
|
||||
DropdownAction,
|
||||
|
@ -29,6 +29,7 @@ export type VideoActionsDisplayType = {
|
|||
liveInfo?: boolean
|
||||
removeFiles?: boolean
|
||||
transcoding?: boolean
|
||||
editor?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
@ -59,7 +60,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
mute: true,
|
||||
liveInfo: false,
|
||||
removeFiles: false,
|
||||
transcoding: false
|
||||
transcoding: false,
|
||||
editor: true
|
||||
}
|
||||
@Input() placement = 'left'
|
||||
|
||||
|
@ -89,7 +91,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
private videoBlocklistService: VideoBlockService,
|
||||
private screenService: ScreenService,
|
||||
private videoService: VideoService,
|
||||
private redundancyService: RedundancyService
|
||||
private redundancyService: RedundancyService,
|
||||
private serverService: ServerService
|
||||
) { }
|
||||
|
||||
get user () {
|
||||
|
@ -149,6 +152,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
return this.video.isUpdatableBy(this.user)
|
||||
}
|
||||
|
||||
isVideoEditable () {
|
||||
return this.serverService.getHTMLConfig().videoEditor.enabled &&
|
||||
this.video.state?.id === VideoState.PUBLISHED &&
|
||||
this.video.isUpdatableBy(this.user)
|
||||
}
|
||||
|
||||
isVideoRemovable () {
|
||||
return this.video.isRemovableBy(this.user)
|
||||
}
|
||||
|
@ -329,6 +338,12 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
iconName: 'edit',
|
||||
isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
|
||||
},
|
||||
{
|
||||
label: $localize`Editor`,
|
||||
linkBuilder: ({ video }) => [ '/video-editor/edit', video.uuid ],
|
||||
iconName: 'film',
|
||||
isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.editor && this.isVideoEditable()
|
||||
},
|
||||
{
|
||||
label: $localize`Block`,
|
||||
handler: () => this.showBlockModal(),
|
||||
|
|
|
@ -195,6 +195,10 @@ export class VideoMiniatureComponent implements OnInit {
|
|||
return $localize`To import`
|
||||
}
|
||||
|
||||
if (video.state.id === VideoState.TO_EDIT) {
|
||||
return $localize`To edit`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
|
|
|
@ -425,6 +425,10 @@ live:
|
|||
1440p: false
|
||||
2160p: false
|
||||
|
||||
video_editor:
|
||||
# Enable video edition by users (cut, add intro/outro, add watermark etc)
|
||||
enabled: false
|
||||
|
||||
import:
|
||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||
videos:
|
||||
|
|
|
@ -433,6 +433,10 @@ live:
|
|||
1440p: false
|
||||
2160p: false
|
||||
|
||||
video_editor:
|
||||
# Enable video edition by users (cut, add intro/outro, add watermark etc)
|
||||
enabled: false
|
||||
|
||||
import:
|
||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||
videos:
|
||||
|
|
|
@ -37,6 +37,9 @@ signup:
|
|||
transcoding:
|
||||
enabled: false
|
||||
|
||||
video_editor:
|
||||
enabled: false
|
||||
|
||||
live:
|
||||
rtmp:
|
||||
port: 1936
|
||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
|||
|
||||
transcoding:
|
||||
enabled: false
|
||||
|
||||
video_editor:
|
||||
enabled: false
|
||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
|||
|
||||
transcoding:
|
||||
enabled: false
|
||||
|
||||
video_editor:
|
||||
enabled: false
|
||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
|||
|
||||
transcoding:
|
||||
enabled: false
|
||||
|
||||
video_editor:
|
||||
enabled: false
|
||||
|
|
|
@ -30,3 +30,6 @@ admin:
|
|||
|
||||
transcoding:
|
||||
enabled: false
|
||||
|
||||
video_editor:
|
||||
enabled: false
|
||||
|
|
|
@ -164,3 +164,6 @@ views:
|
|||
|
||||
local_buffer_update_interval: '5 seconds'
|
||||
ip_view_expiration: '1 second'
|
||||
|
||||
video_editor:
|
||||
enabled: true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { program } from 'commander'
|
||||
import { isUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
|
||||
import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
|
||||
import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
|
||||
import { CONFIG } from '@server/initializers/config'
|
||||
import { addTranscodingJob } from '@server/lib/video'
|
||||
import { VideoState, VideoTranscodingPayload } from '@shared/models'
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { program } from 'commander'
|
||||
import ffmpeg from 'fluent-ffmpeg'
|
||||
import { exit } from 'process'
|
||||
import { buildx264VODCommand, runCommand, TranscodeOptions } from '@server/helpers/ffmpeg-utils'
|
||||
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/video-transcoding-profiles'
|
||||
import { buildVODCommand, runCommand, TranscodeVODOptions } from '@server/helpers/ffmpeg'
|
||||
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
|
||||
|
||||
program
|
||||
.arguments('<path>')
|
||||
|
@ -33,12 +33,12 @@ async function run (path: string, cmd: any) {
|
|||
|
||||
resolution: +cmd.resolution,
|
||||
isPortraitMode: false
|
||||
} as TranscodeOptions
|
||||
} as TranscodeVODOptions
|
||||
|
||||
let command = ffmpeg(options.inputPath)
|
||||
.output(options.outputPath)
|
||||
|
||||
command = await buildx264VODCommand(command, options)
|
||||
command = await buildVODCommand(command, options)
|
||||
|
||||
command.on('start', (cmdline) => {
|
||||
console.log(cmdline)
|
||||
|
|
|
@ -42,10 +42,7 @@ try {
|
|||
|
||||
import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
|
||||
|
||||
const errorMessage = checkConfig()
|
||||
if (errorMessage !== null) {
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
checkConfig()
|
||||
|
||||
// Trust our proxy (IP forwarding...)
|
||||
app.set('trust proxy', CONFIG.TRUST_PROXY)
|
||||
|
|
|
@ -256,6 +256,9 @@ function customConfig (): CustomConfig {
|
|||
}
|
||||
}
|
||||
},
|
||||
videoEditor: {
|
||||
enabled: CONFIG.VIDEO_EDITOR.ENABLED
|
||||
},
|
||||
import: {
|
||||
videos: {
|
||||
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
|
||||
|
|
|
@ -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 { videoCaptionsRouter } from './captions'
|
||||
import { videoCommentRouter } from './comment'
|
||||
import { editorRouter } from './editor'
|
||||
import { filesRouter } from './files'
|
||||
import { videoImportsRouter } from './import'
|
||||
import { liveRouter } from './live'
|
||||
|
@ -51,6 +52,7 @@ const videosRouter = express.Router()
|
|||
videosRouter.use('/', blacklistRouter)
|
||||
videosRouter.use('/', rateVideoRouter)
|
||||
videosRouter.use('/', videoCommentRouter)
|
||||
videosRouter.use('/', editorRouter)
|
||||
videosRouter.use('/', videoCaptionsRouter)
|
||||
videosRouter.use('/', videoImportsRouter)
|
||||
videosRouter.use('/', ownershipVideoRouter)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import express from 'express'
|
||||
import { computeLowerResolutionsToTranscode } from '@server/helpers/ffprobe-utils'
|
||||
import { computeLowerResolutionsToTranscode } from '@server/helpers/ffmpeg'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||
import { addTranscodingJob } from '@server/lib/video'
|
||||
import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@shared/models'
|
||||
|
@ -29,7 +29,7 @@ async function createTranscoding (req: express.Request, res: express.Response) {
|
|||
|
||||
const body: VideoTranscodingCreate = req.body
|
||||
|
||||
const { resolution: maxResolution, isPortraitMode, audioStream } = await video.getMaxQualityFileInfo()
|
||||
const { resolution: maxResolution, isPortraitMode, audioStream } = await video.probeMaxQualityFile()
|
||||
const resolutions = computeLowerResolutionsToTranscode(maxResolution, 'vod').concat([ maxResolution ])
|
||||
|
||||
video.state = VideoState.TO_TRANSCODE
|
||||
|
|
|
@ -24,7 +24,7 @@ import { HttpStatusCode, VideoCreate, VideoResolution, VideoState } from '@share
|
|||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
||||
import { createReqFiles } from '../../../helpers/express-utils'
|
||||
import { ffprobePromise, getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
|
||||
import { ffprobePromise, buildFileMetadata, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
|
||||
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||
import { CONFIG } from '../../../initializers/config'
|
||||
import { MIMETYPES } from '../../../initializers/constants'
|
||||
|
@ -246,7 +246,7 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
|
|||
extname: getLowercaseExtension(videoPhysicalFile.filename),
|
||||
size: videoPhysicalFile.size,
|
||||
videoStreamingPlaylistId: null,
|
||||
metadata: await getMetadataFromFile(videoPhysicalFile.path)
|
||||
metadata: await buildFileMetadata(videoPhysicalFile.path)
|
||||
})
|
||||
|
||||
const probe = await ffprobePromise(videoPhysicalFile.path)
|
||||
|
@ -254,8 +254,8 @@ async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
|
|||
if (await isAudioFile(videoPhysicalFile.path, probe)) {
|
||||
videoFile.resolution = VideoResolution.H_NOVIDEO
|
||||
} else {
|
||||
videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path, probe)
|
||||
videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path, probe)).resolution
|
||||
videoFile.fps = await getVideoStreamFPS(videoPhysicalFile.path, probe)
|
||||
videoFile.resolution = (await getVideoStreamDimensionsInfo(videoPhysicalFile.path, probe)).resolution
|
||||
}
|
||||
|
||||
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
import { UploadFilesForCheck } from 'express'
|
||||
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
|
||||
import { isFileValid } from './misc'
|
||||
|
||||
|
@ -6,8 +7,14 @@ const imageMimeTypes = CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
|
|||
.map(v => v.replace('.', ''))
|
||||
.join('|')
|
||||
const imageMimeTypesRegex = `image/(${imageMimeTypes})`
|
||||
function isActorImageFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], fieldname: string) {
|
||||
return isFileValid(files, imageMimeTypesRegex, fieldname, CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max)
|
||||
|
||||
function isActorImageFile (files: UploadFilesForCheck, fieldname: string) {
|
||||
return isFileValid({
|
||||
files,
|
||||
mimeTypeRegex: imageMimeTypesRegex,
|
||||
field: fieldname,
|
||||
maxSize: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -61,75 +61,43 @@ function isIntOrNull (value: any) {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isFileFieldValid (
|
||||
files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
|
||||
field: string,
|
||||
optional = false
|
||||
) {
|
||||
function isFileValid (options: {
|
||||
files: UploadFilesForCheck
|
||||
|
||||
maxSize: number | null
|
||||
mimeTypeRegex: string | null
|
||||
|
||||
field?: string
|
||||
|
||||
optional?: boolean // Default false
|
||||
}) {
|
||||
const { files, mimeTypeRegex, field, maxSize, optional = false } = options
|
||||
|
||||
// Should have files
|
||||
if (!files) return optional
|
||||
if (isArray(files)) return optional
|
||||
|
||||
// Should have a file
|
||||
const fileArray = files[field]
|
||||
if (!fileArray || fileArray.length === 0) {
|
||||
const fileArray = isArray(files)
|
||||
? files
|
||||
: files[field]
|
||||
|
||||
if (!fileArray || !isArray(fileArray) || fileArray.length === 0) {
|
||||
return optional
|
||||
}
|
||||
|
||||
// The file should exist
|
||||
const file = fileArray[0]
|
||||
if (!file || !file.originalname) return false
|
||||
return file
|
||||
}
|
||||
|
||||
function isFileMimeTypeValid (
|
||||
files: UploadFilesForCheck,
|
||||
mimeTypeRegex: string,
|
||||
field: string,
|
||||
optional = false
|
||||
) {
|
||||
// Should have files
|
||||
if (!files) return optional
|
||||
if (isArray(files)) return optional
|
||||
|
||||
// Should have a file
|
||||
const fileArray = files[field]
|
||||
if (!fileArray || fileArray.length === 0) {
|
||||
return optional
|
||||
}
|
||||
|
||||
// The file should exist
|
||||
const file = fileArray[0]
|
||||
if (!file || !file.originalname) return false
|
||||
|
||||
return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
|
||||
}
|
||||
|
||||
function isFileValid (
|
||||
files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
|
||||
mimeTypeRegex: string,
|
||||
field: string,
|
||||
maxSize: number | null,
|
||||
optional = false
|
||||
) {
|
||||
// Should have files
|
||||
if (!files) return optional
|
||||
if (isArray(files)) return optional
|
||||
|
||||
// Should have a file
|
||||
const fileArray = files[field]
|
||||
if (!fileArray || fileArray.length === 0) {
|
||||
return optional
|
||||
}
|
||||
|
||||
// The file should exist
|
||||
// The file exists
|
||||
const file = fileArray[0]
|
||||
if (!file || !file.originalname) return false
|
||||
|
||||
// Check size
|
||||
if ((maxSize !== null) && file.size > maxSize) return false
|
||||
|
||||
return new RegExp(`^${mimeTypeRegex}$`, 'i').test(file.mimetype)
|
||||
if (mimeTypeRegex === null) return true
|
||||
|
||||
return checkMimetypeRegex(file.mimetype, mimeTypeRegex)
|
||||
}
|
||||
|
||||
function checkMimetypeRegex (fileMimeType: string, mimeTypeRegex: string) {
|
||||
return new RegExp(`^${mimeTypeRegex}$`, 'i').test(fileMimeType)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -204,7 +172,6 @@ export {
|
|||
areUUIDsValid,
|
||||
toArray,
|
||||
toIntArray,
|
||||
isFileFieldValid,
|
||||
isFileMimeTypeValid,
|
||||
isFileValid
|
||||
isFileValid,
|
||||
checkMimetypeRegex
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { getFileSize } from '@shared/extra-utils'
|
||||
import { UploadFilesForCheck } from 'express'
|
||||
import { readFile } from 'fs-extra'
|
||||
import { getFileSize } from '@shared/extra-utils'
|
||||
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers/constants'
|
||||
import { exists, isFileValid } from './misc'
|
||||
|
||||
|
@ -11,8 +12,13 @@ const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT
|
|||
.concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
|
||||
.map(m => `(${m})`)
|
||||
.join('|')
|
||||
function isVideoCaptionFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
|
||||
return isFileValid(files, videoCaptionTypesRegex, field, CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max)
|
||||
function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
|
||||
return isFileValid({
|
||||
files,
|
||||
mimeTypeRegex: videoCaptionTypesRegex,
|
||||
field,
|
||||
maxSize: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
|
||||
})
|
||||
}
|
||||
|
||||
async function isVTTFileValid (filePath: string) {
|
||||
|
|
|
@ -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 { UploadFilesForCheck } from 'express'
|
||||
import validator from 'validator'
|
||||
import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
|
||||
import { exists, isFileValid } from './misc'
|
||||
|
@ -25,8 +26,14 @@ const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT)
|
|||
.concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream
|
||||
.map(m => `(${m})`)
|
||||
.join('|')
|
||||
function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
|
||||
return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
|
||||
function isVideoImportTorrentFile (files: UploadFilesForCheck) {
|
||||
return isFileValid({
|
||||
files,
|
||||
mimeTypeRegex: videoTorrentImportRegex,
|
||||
field: 'torrentfile',
|
||||
maxSize: CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max,
|
||||
optional: true
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -13,7 +13,7 @@ import {
|
|||
VIDEO_RATE_TYPES,
|
||||
VIDEO_STATES
|
||||
} from '../../initializers/constants'
|
||||
import { exists, isArray, isDateValid, isFileMimeTypeValid, isFileValid } from './misc'
|
||||
import { exists, isArray, isDateValid, isFileValid } from './misc'
|
||||
|
||||
const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
|
||||
|
||||
|
@ -66,7 +66,7 @@ function isVideoTagValid (tag: string) {
|
|||
return exists(tag) && validator.isLength(tag, VIDEOS_CONSTRAINTS_FIELDS.TAG)
|
||||
}
|
||||
|
||||
function isVideoTagsValid (tags: string[]) {
|
||||
function areVideoTagsValid (tags: string[]) {
|
||||
return tags === null || (
|
||||
isArray(tags) &&
|
||||
validator.isInt(tags.length.toString(), VIDEOS_CONSTRAINTS_FIELDS.TAGS) &&
|
||||
|
@ -86,8 +86,13 @@ function isVideoFileExtnameValid (value: string) {
|
|||
return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
|
||||
}
|
||||
|
||||
function isVideoFileMimeTypeValid (files: UploadFilesForCheck) {
|
||||
return isFileMimeTypeValid(files, MIMETYPES.VIDEO.MIMETYPES_REGEX, 'videofile')
|
||||
function isVideoFileMimeTypeValid (files: UploadFilesForCheck, field = 'videofile') {
|
||||
return isFileValid({
|
||||
files,
|
||||
mimeTypeRegex: MIMETYPES.VIDEO.MIMETYPES_REGEX,
|
||||
field,
|
||||
maxSize: null
|
||||
})
|
||||
}
|
||||
|
||||
const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
|
||||
|
@ -95,8 +100,14 @@ const videoImageTypes = CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME
|
|||
.join('|')
|
||||
const videoImageTypesRegex = `image/(${videoImageTypes})`
|
||||
|
||||
function isVideoImage (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], field: string) {
|
||||
return isFileValid(files, videoImageTypesRegex, field, CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max, true)
|
||||
function isVideoImageValid (files: UploadFilesForCheck, field: string, optional = true) {
|
||||
return isFileValid({
|
||||
files,
|
||||
mimeTypeRegex: videoImageTypesRegex,
|
||||
field,
|
||||
maxSize: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max,
|
||||
optional
|
||||
})
|
||||
}
|
||||
|
||||
function isVideoPrivacyValid (value: number) {
|
||||
|
@ -144,7 +155,7 @@ export {
|
|||
isVideoDescriptionValid,
|
||||
isVideoFileInfoHashValid,
|
||||
isVideoNameValid,
|
||||
isVideoTagsValid,
|
||||
areVideoTagsValid,
|
||||
isVideoFPSResolutionValid,
|
||||
isScheduleVideoUpdatePrivacyValid,
|
||||
isVideoOriginallyPublishedAtValid,
|
||||
|
@ -160,7 +171,7 @@ export {
|
|||
isVideoPrivacyValid,
|
||||
isVideoFileResolutionValid,
|
||||
isVideoFileSizeValid,
|
||||
isVideoImage,
|
||||
isVideoImageValid,
|
||||
isVideoSupportValid,
|
||||
isVideoFilterValid
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import express, { RequestHandler } from 'express'
|
||||
import multer, { diskStorage } from 'multer'
|
||||
import { getLowercaseExtension } from '@shared/core-utils'
|
||||
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
||||
import { CONFIG } from '../initializers/config'
|
||||
import { REMOTE_SCHEME } from '../initializers/constants'
|
||||
import { getLowercaseExtension } from '@shared/core-utils'
|
||||
import { isArray } from './custom-validators/misc'
|
||||
import { logger } from './logger'
|
||||
import { deleteFileAndCatch, generateRandomString } from './utils'
|
||||
|
@ -75,29 +75,8 @@ function createReqFiles (
|
|||
cb(null, destinations[file.fieldname])
|
||||
},
|
||||
|
||||
filename: async (req, file, cb) => {
|
||||
let extension: string
|
||||
const fileExtension = getLowercaseExtension(file.originalname)
|
||||
const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
|
||||
|
||||
// Take the file extension if we don't understand the mime type
|
||||
if (!extensionFromMimetype) {
|
||||
extension = fileExtension
|
||||
} else {
|
||||
// Take the first available extension for this mimetype
|
||||
extension = extensionFromMimetype
|
||||
}
|
||||
|
||||
let randomString = ''
|
||||
|
||||
try {
|
||||
randomString = await generateRandomString(16)
|
||||
} catch (err) {
|
||||
logger.error('Cannot generate random string for file name.', { err })
|
||||
randomString = 'fake-random-string'
|
||||
}
|
||||
|
||||
cb(null, randomString + extension)
|
||||
filename: (req, file, cb) => {
|
||||
return generateReqFilename(file, mimeTypes, cb)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -112,6 +91,24 @@ function createReqFiles (
|
|||
return multer({ storage }).fields(fields)
|
||||
}
|
||||
|
||||
function createAnyReqFiles (
|
||||
mimeTypes: { [id: string]: string | string[] },
|
||||
destinationDirectory: string,
|
||||
fileFilter: (req: express.Request, file: Express.Multer.File, cb: (err: Error, result: boolean) => void) => void
|
||||
): RequestHandler {
|
||||
const storage = diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, destinationDirectory)
|
||||
},
|
||||
|
||||
filename: (req, file, cb) => {
|
||||
return generateReqFilename(file, mimeTypes, cb)
|
||||
}
|
||||
})
|
||||
|
||||
return multer({ storage, fileFilter }).any()
|
||||
}
|
||||
|
||||
function isUserAbleToSearchRemoteURI (res: express.Response) {
|
||||
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
|
||||
|
||||
|
@ -128,9 +125,41 @@ function getCountVideos (req: express.Request) {
|
|||
export {
|
||||
buildNSFWFilter,
|
||||
getHostWithPort,
|
||||
createAnyReqFiles,
|
||||
isUserAbleToSearchRemoteURI,
|
||||
badRequest,
|
||||
createReqFiles,
|
||||
cleanUpReqFiles,
|
||||
getCountVideos
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function generateReqFilename (
|
||||
file: Express.Multer.File,
|
||||
mimeTypes: { [id: string]: string | string[] },
|
||||
cb: (err: Error, name: string) => void
|
||||
) {
|
||||
let extension: string
|
||||
const fileExtension = getLowercaseExtension(file.originalname)
|
||||
const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
|
||||
|
||||
// Take the file extension if we don't understand the mime type
|
||||
if (!extensionFromMimetype) {
|
||||
extension = fileExtension
|
||||
} else {
|
||||
// Take the first available extension for this mimetype
|
||||
extension = extensionFromMimetype
|
||||
}
|
||||
|
||||
let randomString = ''
|
||||
|
||||
try {
|
||||
randomString = await generateRandomString(16)
|
||||
} catch (err) {
|
||||
logger.error('Cannot generate random string for file name.', { err })
|
||||
randomString = 'fake-random-string'
|
||||
}
|
||||
|
||||
cb(null, randomString + extension)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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'))
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 { getMaxBitrate } from '@shared/core-utils'
|
||||
import { VideoResolution, VideoTranscodingFPS } from '../../shared/models/videos'
|
||||
import { CONFIG } from '../initializers/config'
|
||||
import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
|
||||
import { logger } from './logger'
|
||||
import {
|
||||
canDoQuickAudioTranscode,
|
||||
ffprobePromise,
|
||||
getDurationFromVideoFile,
|
||||
getAudioStream,
|
||||
getVideoStreamDuration,
|
||||
getMaxAudioBitrate,
|
||||
getMetadataFromFile,
|
||||
getVideoFileBitrate,
|
||||
getVideoFileFPS,
|
||||
getVideoFileResolution,
|
||||
getVideoStreamFromFile,
|
||||
getVideoStreamSize
|
||||
buildFileMetadata,
|
||||
getVideoStreamBitrate,
|
||||
getVideoStreamFPS,
|
||||
getVideoStream,
|
||||
getVideoStreamDimensionsInfo,
|
||||
hasAudioStream
|
||||
} from '@shared/extra-utils/ffprobe'
|
||||
import { VideoResolution, VideoTranscodingFPS } from '@shared/models'
|
||||
import { CONFIG } from '../../initializers/config'
|
||||
import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
|
||||
import { logger } from '../logger'
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -24,9 +23,12 @@ import {
|
|||
*
|
||||
*/
|
||||
|
||||
async function getVideoStreamCodec (path: string) {
|
||||
const videoStream = await getVideoStreamFromFile(path)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codecs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getVideoStreamCodec (path: string) {
|
||||
const videoStream = await getVideoStream(path)
|
||||
if (!videoStream) return ''
|
||||
|
||||
const videoCodec = videoStream.codec_tag_string
|
||||
|
@ -83,6 +85,10 @@ async function getAudioStreamCodec (path: string, existingProbe?: FfprobeData) {
|
|||
return 'mp4a.40.2' // Fallback
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolutions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
|
||||
const configResolutions = type === 'vod'
|
||||
? CONFIG.TRANSCODING.RESOLUTIONS
|
||||
|
@ -112,6 +118,10 @@ function computeLowerResolutionsToTranscode (videoFileResolution: number, type:
|
|||
return resolutionsEnabled
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Can quick transcode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function canDoQuickTranscode (path: string): Promise<boolean> {
|
||||
if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
|
||||
|
||||
|
@ -121,17 +131,37 @@ async function canDoQuickTranscode (path: string): Promise<boolean> {
|
|||
await canDoQuickAudioTranscode(path, probe)
|
||||
}
|
||||
|
||||
async function canDoQuickAudioTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||
const parsedAudio = await getAudioStream(path, probe)
|
||||
|
||||
if (!parsedAudio.audioStream) return true
|
||||
|
||||
if (parsedAudio.audioStream['codec_name'] !== 'aac') return false
|
||||
|
||||
const audioBitrate = parsedAudio.bitrate
|
||||
if (!audioBitrate) return false
|
||||
|
||||
const maxAudioBitrate = getMaxAudioBitrate('aac', audioBitrate)
|
||||
if (maxAudioBitrate !== -1 && audioBitrate > maxAudioBitrate) return false
|
||||
|
||||
const channelLayout = parsedAudio.audioStream['channel_layout']
|
||||
// Causes playback issues with Chrome
|
||||
if (!channelLayout || channelLayout === 'unknown') return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Promise<boolean> {
|
||||
const videoStream = await getVideoStreamFromFile(path, probe)
|
||||
const fps = await getVideoFileFPS(path, probe)
|
||||
const bitRate = await getVideoFileBitrate(path, probe)
|
||||
const resolutionData = await getVideoFileResolution(path, probe)
|
||||
const videoStream = await getVideoStream(path, probe)
|
||||
const fps = await getVideoStreamFPS(path, probe)
|
||||
const bitRate = await getVideoStreamBitrate(path, probe)
|
||||
const resolutionData = await getVideoStreamDimensionsInfo(path, probe)
|
||||
|
||||
// If ffprobe did not manage to guess the bitrate
|
||||
if (!bitRate) return false
|
||||
|
||||
// check video params
|
||||
if (videoStream == null) return false
|
||||
if (!videoStream) return false
|
||||
if (videoStream['codec_name'] !== 'h264') return false
|
||||
if (videoStream['pix_fmt'] !== 'yuv420p') return false
|
||||
if (fps < VIDEO_TRANSCODING_FPS.MIN || fps > VIDEO_TRANSCODING_FPS.MAX) return false
|
||||
|
@ -140,6 +170,10 @@ async function canDoQuickVideoTranscode (path: string, probe?: FfprobeData): Pro
|
|||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framerate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getClosestFramerateStandard <K extends keyof Pick<VideoTranscodingFPS, 'HD_STANDARD' | 'STANDARD'>> (fps: number, type: K) {
|
||||
return VIDEO_TRANSCODING_FPS[type].slice(0)
|
||||
.sort((a, b) => fps % a - fps % b)[0]
|
||||
|
@ -171,21 +205,26 @@ function computeFPS (fpsArg: number, resolution: VideoResolution) {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
// Re export ffprobe utils
|
||||
getVideoStreamDimensionsInfo,
|
||||
buildFileMetadata,
|
||||
getMaxAudioBitrate,
|
||||
getVideoStream,
|
||||
getVideoStreamDuration,
|
||||
getAudioStream,
|
||||
hasAudioStream,
|
||||
getVideoStreamFPS,
|
||||
ffprobePromise,
|
||||
getVideoStreamBitrate,
|
||||
|
||||
getVideoStreamCodec,
|
||||
getAudioStreamCodec,
|
||||
getVideoStreamSize,
|
||||
getVideoFileResolution,
|
||||
getMetadataFromFile,
|
||||
getMaxAudioBitrate,
|
||||
getVideoStreamFromFile,
|
||||
getDurationFromVideoFile,
|
||||
getAudioStream,
|
||||
|
||||
computeFPS,
|
||||
getVideoFileFPS,
|
||||
ffprobePromise,
|
||||
getClosestFramerateStandard,
|
||||
|
||||
computeLowerResolutionsToTranscode,
|
||||
getVideoFileBitrate,
|
||||
|
||||
canDoQuickTranscode,
|
||||
canDoQuickVideoTranscode,
|
||||
canDoQuickAudioTranscode
|
|
@ -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 Jimp, { read } from 'jimp'
|
||||
import { join } from 'path'
|
||||
import { getLowercaseExtension } from '@shared/core-utils'
|
||||
import { buildUUID } from '@shared/extra-utils'
|
||||
import { convertWebPToJPG, processGIF } from './ffmpeg-utils'
|
||||
import { logger } from './logger'
|
||||
import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
|
||||
import { logger, loggerTagsFactory } from './logger'
|
||||
|
||||
const lTags = loggerTagsFactory('image-utils')
|
||||
|
||||
function generateImageFilename (extension = '.jpg') {
|
||||
return buildUUID() + extension
|
||||
|
@ -33,10 +36,31 @@ async function processImage (
|
|||
if (keepOriginal !== true) await remove(path)
|
||||
}
|
||||
|
||||
async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
|
||||
const pendingImageName = 'pending-' + imageName
|
||||
const pendingImagePath = join(folder, pendingImageName)
|
||||
|
||||
try {
|
||||
await generateThumbnailFromVideo(fromPath, folder, imageName)
|
||||
|
||||
const destination = join(folder, imageName)
|
||||
await processImage(pendingImagePath, destination, size)
|
||||
} catch (err) {
|
||||
logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })
|
||||
|
||||
try {
|
||||
await remove(pendingImagePath)
|
||||
} catch (err) {
|
||||
logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
generateImageFilename,
|
||||
generateImageFromVideoFile,
|
||||
processImage
|
||||
}
|
||||
|
||||
|
|
|
@ -91,6 +91,16 @@ async function downloadWebTorrentVideo (target: { uri: string, torrentName?: str
|
|||
}
|
||||
|
||||
function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
|
||||
return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), videoPath => {
|
||||
return createTorrentAndSetInfoHashFromPath(videoOrPlaylist, videoFile, videoPath)
|
||||
})
|
||||
}
|
||||
|
||||
async function createTorrentAndSetInfoHashFromPath (
|
||||
videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
|
||||
videoFile: MVideoFile,
|
||||
filePath: string
|
||||
) {
|
||||
const video = extractVideo(videoOrPlaylist)
|
||||
|
||||
const options = {
|
||||
|
@ -101,24 +111,22 @@ function createTorrentAndSetInfoHash (videoOrPlaylist: MVideo | MStreamingPlayli
|
|||
urlList: buildUrlList(video, videoFile)
|
||||
}
|
||||
|
||||
return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(videoOrPlaylist), async videoPath => {
|
||||
const torrentContent = await createTorrentPromise(videoPath, options)
|
||||
const torrentContent = await createTorrentPromise(filePath, options)
|
||||
|
||||
const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
|
||||
const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
|
||||
logger.info('Creating torrent %s.', torrentPath)
|
||||
const torrentFilename = generateTorrentFileName(videoOrPlaylist, videoFile.resolution)
|
||||
const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, torrentFilename)
|
||||
logger.info('Creating torrent %s.', torrentPath)
|
||||
|
||||
await writeFile(torrentPath, torrentContent)
|
||||
await writeFile(torrentPath, torrentContent)
|
||||
|
||||
// Remove old torrent file if it existed
|
||||
if (videoFile.hasTorrent()) {
|
||||
await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
|
||||
}
|
||||
// Remove old torrent file if it existed
|
||||
if (videoFile.hasTorrent()) {
|
||||
await remove(join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename))
|
||||
}
|
||||
|
||||
const parsedTorrent = parseTorrent(torrentContent)
|
||||
videoFile.infoHash = parsedTorrent.infoHash
|
||||
videoFile.torrentFilename = torrentFilename
|
||||
})
|
||||
const parsedTorrent = parseTorrent(torrentContent)
|
||||
videoFile.infoHash = parsedTorrent.infoHash
|
||||
videoFile.torrentFilename = torrentFilename
|
||||
}
|
||||
|
||||
async function updateTorrentMetadata (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
|
||||
|
@ -177,7 +185,10 @@ function generateMagnetUri (
|
|||
export {
|
||||
createTorrentPromise,
|
||||
updateTorrentMetadata,
|
||||
|
||||
createTorrentAndSetInfoHash,
|
||||
createTorrentAndSetInfoHashFromPath,
|
||||
|
||||
generateMagnetUri,
|
||||
downloadWebTorrentVideo
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import config from 'config'
|
||||
import { uniq } from 'lodash'
|
||||
import { URL } from 'url'
|
||||
import { getFFmpegVersion } from '@server/helpers/ffmpeg-utils'
|
||||
import { getFFmpegVersion } from '@server/helpers/ffmpeg'
|
||||
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
|
||||
import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
|
||||
import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
|
||||
|
@ -31,8 +31,7 @@ async function checkActivityPubUrls () {
|
|||
}
|
||||
}
|
||||
|
||||
// Some checks on configuration files
|
||||
// Return an error message, or null if everything is okay
|
||||
// Some checks on configuration files or throw if there is an error
|
||||
function checkConfig () {
|
||||
|
||||
// Moved configuration keys
|
||||
|
@ -40,157 +39,17 @@ function checkConfig () {
|
|||
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
|
||||
}
|
||||
|
||||
// Email verification
|
||||
if (!isEmailEnabled()) {
|
||||
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
|
||||
return 'Emailer is disabled but you require signup email verification.'
|
||||
}
|
||||
|
||||
if (CONFIG.CONTACT_FORM.ENABLED) {
|
||||
logger.warn('Emailer is disabled so the contact form will not work.')
|
||||
}
|
||||
}
|
||||
|
||||
// NSFW policy
|
||||
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
|
||||
checkEmailConfig()
|
||||
checkNSFWPolicyConfig()
|
||||
checkLocalRedundancyConfig()
|
||||
checkRemoteRedundancyConfig()
|
||||
checkStorageConfig()
|
||||
checkTranscodingConfig()
|
||||
checkBroadcastMessageConfig()
|
||||
checkSearchConfig()
|
||||
checkLiveConfig()
|
||||
checkObjectStorageConfig()
|
||||
checkVideoEditorConfig()
|
||||
}
|
||||
|
||||
// We get db by param to not import it in this file (import orders)
|
||||
|
@ -233,3 +92,176 @@ export {
|
|||
applicationExist,
|
||||
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.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
|
||||
'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
|
||||
'transcoding.resolutions.2160p',
|
||||
'transcoding.resolutions.2160p', 'video_editor.enabled',
|
||||
'import.videos.http.enabled', 'import.videos.torrent.enabled', 'import.videos.concurrency', 'auto_blacklist.videos.of_users.enabled',
|
||||
'trending.videos.interval_days',
|
||||
'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
|
||||
|
|
|
@ -324,6 +324,9 @@ const CONFIG = {
|
|||
}
|
||||
}
|
||||
},
|
||||
VIDEO_EDITOR: {
|
||||
get ENABLED () { return config.get<boolean>('video_editor.enabled') }
|
||||
},
|
||||
IMPORT: {
|
||||
VIDEOS: {
|
||||
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
|
||||
|
|
|
@ -152,6 +152,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
|
|||
'activitypub-refresher': 1,
|
||||
'video-redundancy': 1,
|
||||
'video-live-ending': 1,
|
||||
'video-edition': 1,
|
||||
'move-to-object-storage': 3
|
||||
}
|
||||
// Excluded keys are jobs that can be configured by admins
|
||||
|
@ -168,6 +169,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
|
|||
'activitypub-refresher': 1,
|
||||
'video-redundancy': 1,
|
||||
'video-live-ending': 10,
|
||||
'video-edition': 1,
|
||||
'move-to-object-storage': 1
|
||||
}
|
||||
const JOB_TTL: { [id in JobType]: number } = {
|
||||
|
@ -178,6 +180,7 @@ const JOB_TTL: { [id in JobType]: number } = {
|
|||
'activitypub-cleaner': 1000 * 3600, // 1 hour
|
||||
'video-file-import': 1000 * 3600, // 1 hour
|
||||
'video-transcoding': 1000 * 3600 * 48, // 2 days, transcoding could be long
|
||||
'video-edition': 1000 * 3600 * 10, // 10 hours
|
||||
'video-import': 1000 * 3600 * 2, // 2 hours
|
||||
'email': 60000 * 10, // 10 minutes
|
||||
'actor-keys': 60000 * 20, // 20 minutes
|
||||
|
@ -351,6 +354,10 @@ const CONSTRAINTS_FIELDS = {
|
|||
},
|
||||
COMMONS: {
|
||||
URL: { min: 5, max: 2000 } // Length
|
||||
},
|
||||
VIDEO_EDITOR: {
|
||||
TASKS: { min: 1, max: 10 }, // Number of tasks
|
||||
CUT_TIME: { min: 0 } // Value
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -365,6 +372,7 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
|
|||
MIN: 1,
|
||||
STANDARD: [ 24, 25, 30 ],
|
||||
HD_STANDARD: [ 50, 60 ],
|
||||
AUDIO_MERGE: 25,
|
||||
AVERAGE: 30,
|
||||
MAX: 60,
|
||||
KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
|
||||
|
@ -434,7 +442,8 @@ const VIDEO_STATES: { [ id in VideoState ]: string } = {
|
|||
[VideoState.LIVE_ENDED]: 'Livestream ended',
|
||||
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE]: 'To move to an external storage',
|
||||
[VideoState.TRANSCODING_FAILED]: 'Transcoding failed',
|
||||
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed'
|
||||
[VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED]: 'External storage move failed',
|
||||
[VideoState.TO_EDIT]: 'To edit*'
|
||||
}
|
||||
|
||||
const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
|
||||
|
@ -855,6 +864,16 @@ const FILES_CONTENT_HASH = {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VIDEO_FILTERS = {
|
||||
WATERMARK: {
|
||||
SIZE_RATIO: 1 / 10,
|
||||
HORIZONTAL_MARGIN_RATIO: 1 / 20,
|
||||
VERTICAL_MARGIN_RATIO: 1 / 20
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
WEBSERVER,
|
||||
API_VERSION,
|
||||
|
@ -893,6 +912,7 @@ export {
|
|||
PLUGIN_GLOBAL_CSS_FILE_NAME,
|
||||
PLUGIN_GLOBAL_CSS_PATH,
|
||||
PRIVATE_RSA_KEY_SIZE,
|
||||
VIDEO_FILTERS,
|
||||
ROUTE_CACHE_LIFETIME,
|
||||
SORTABLE_COLUMNS,
|
||||
HLS_STREAMING_PLAYLIST_DIRECTORY,
|
||||
|
|
|
@ -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 { join } from 'path'
|
||||
import * as Sequelize from 'sequelize'
|
||||
import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg/ffprobe-utils'
|
||||
import { CONFIG } from '../../initializers/config'
|
||||
|
||||
function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
|
@ -26,7 +26,7 @@ function up (utils: {
|
|||
const uuid = matches[1]
|
||||
const ext = matches[2]
|
||||
|
||||
const p = getVideoFileResolution(join(videoFileDir, videoFile))
|
||||
const p = getVideoStreamDimensionsInfo(join(videoFileDir, videoFile))
|
||||
.then(async ({ resolution }) => {
|
||||
const oldTorrentName = uuid + '.torrent'
|
||||
const newTorrentName = uuid + '-' + resolution + '.torrent'
|
||||
|
|
|
@ -4,7 +4,7 @@ import { basename, dirname, join } from 'path'
|
|||
import { MStreamingPlaylistFilesVideo, MVideo, MVideoUUID } from '@server/types/models'
|
||||
import { sha256 } from '@shared/extra-utils'
|
||||
import { VideoStorage } from '@shared/models'
|
||||
import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamSize } from '../helpers/ffprobe-utils'
|
||||
import { getAudioStreamCodec, getVideoStreamCodec, getVideoStreamDimensionsInfo } from '../helpers/ffmpeg'
|
||||
import { logger } from '../helpers/logger'
|
||||
import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
|
||||
import { generateRandomString } from '../helpers/utils'
|
||||
|
@ -40,10 +40,10 @@ async function updateMasterHLSPlaylist (video: MVideo, playlist: MStreamingPlayl
|
|||
const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
|
||||
|
||||
await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
|
||||
const size = await getVideoStreamSize(videoFilePath)
|
||||
const size = await getVideoStreamDimensionsInfo(videoFilePath)
|
||||
|
||||
const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
|
||||
const resolution = `RESOLUTION=${size.width}x${size.height}`
|
||||
const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
|
||||
|
||||
let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
|
||||
if (file.fps) line += ',FRAME-RATE=' + file.fps
|
||||
|
|
|
@ -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 { copy, stat } from 'fs-extra'
|
||||
import { getLowercaseExtension } from '@shared/core-utils'
|
||||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
||||
import { CONFIG } from '@server/initializers/config'
|
||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
||||
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
|
||||
import { addMoveToObjectStorageJob } from '@server/lib/video'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||
import { VideoModel } from '@server/models/video/video'
|
||||
import { VideoFileModel } from '@server/models/video/video-file'
|
||||
import { MVideoFullLight } from '@server/types/models'
|
||||
import { getLowercaseExtension } from '@shared/core-utils'
|
||||
import { VideoFileImportPayload, VideoStorage } from '@shared/models'
|
||||
import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
|
||||
import { getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { VideoFileModel } from '../../../models/video/video-file'
|
||||
|
||||
async function processVideoFileImport (job: Job) {
|
||||
const payload = job.data as VideoFileImportPayload
|
||||
|
@ -45,9 +45,9 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
|
||||
const { resolution } = await getVideoFileResolution(inputFilePath)
|
||||
const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath)
|
||||
const { size } = await stat(inputFilePath)
|
||||
const fps = await getVideoFileFPS(inputFilePath)
|
||||
const fps = await getVideoStreamFPS(inputFilePath)
|
||||
|
||||
const fileExt = getLowercaseExtension(inputFilePath)
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
VideoResolution,
|
||||
VideoState
|
||||
} from '@shared/models'
|
||||
import { ffprobePromise, getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
|
||||
import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '../../../helpers/ffmpeg'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { getSecureTorrentName } from '../../../helpers/utils'
|
||||
import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
|
||||
|
@ -121,10 +121,10 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
|
|||
|
||||
const { resolution } = await isAudioFile(tempVideoPath, probe)
|
||||
? { resolution: VideoResolution.H_NOVIDEO }
|
||||
: await getVideoFileResolution(tempVideoPath)
|
||||
: await getVideoStreamDimensionsInfo(tempVideoPath)
|
||||
|
||||
const fps = await getVideoFileFPS(tempVideoPath, probe)
|
||||
const duration = await getDurationFromVideoFile(tempVideoPath, probe)
|
||||
const fps = await getVideoStreamFPS(tempVideoPath, probe)
|
||||
const duration = await getVideoStreamDuration(tempVideoPath, probe)
|
||||
|
||||
// Prepare video file object for creation in database
|
||||
const fileExt = getLowercaseExtension(tempVideoPath)
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Job } from 'bull'
|
||||
import { pathExists, readdir, remove } from 'fs-extra'
|
||||
import { join } from 'path'
|
||||
import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
|
||||
import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
|
||||
import { VIDEO_LIVE } from '@server/initializers/constants'
|
||||
import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
|
||||
import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths'
|
||||
import { generateVideoMiniature } from '@server/lib/thumbnail'
|
||||
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding'
|
||||
import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||
import { moveToNextState } from '@server/lib/video-state'
|
||||
import { VideoModel } from '@server/models/video/video'
|
||||
|
@ -96,7 +96,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
|
|||
const probe = await ffprobePromise(concatenatedTsFilePath)
|
||||
const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
|
||||
|
||||
const { resolution, isPortraitMode } = await getVideoFileResolution(concatenatedTsFilePath, probe)
|
||||
const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
|
||||
|
||||
const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
|
||||
video: videoWithFiles,
|
||||
|
@ -107,7 +107,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
|
|||
})
|
||||
|
||||
if (!durationDone) {
|
||||
videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
|
||||
videoWithFiles.duration = await getVideoStreamDuration(outputPath)
|
||||
await videoWithFiles.save()
|
||||
|
||||
durationDone = true
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Job } from 'bull'
|
||||
import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils'
|
||||
import { TranscodeVODOptionsType } from '@server/helpers/ffmpeg'
|
||||
import { addTranscodingJob, getTranscodingJobPriority } from '@server/lib/video'
|
||||
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||
import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
|
||||
|
@ -16,7 +16,7 @@ import {
|
|||
VideoTranscodingPayload
|
||||
} from '@shared/models'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
||||
import { computeLowerResolutionsToTranscode } from '../../../helpers/ffprobe-utils'
|
||||
import { computeLowerResolutionsToTranscode } from '../../../helpers/ffmpeg'
|
||||
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||
import { CONFIG } from '../../../initializers/config'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
|
@ -25,7 +25,7 @@ import {
|
|||
mergeAudioVideofile,
|
||||
optimizeOriginalVideofile,
|
||||
transcodeNewWebTorrentResolution
|
||||
} from '../../transcoding/video-transcoding'
|
||||
} from '../../transcoding/transcoding'
|
||||
|
||||
type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
|
||||
|
||||
|
@ -174,10 +174,10 @@ async function onHlsPlaylistGeneration (video: MVideoFullLight, user: MUser, pay
|
|||
async function onVideoFirstWebTorrentTranscoding (
|
||||
videoArg: MVideoWithFile,
|
||||
payload: OptimizeTranscodingPayload | MergeAudioTranscodingPayload,
|
||||
transcodeType: TranscodeOptionsType,
|
||||
transcodeType: TranscodeVODOptionsType,
|
||||
user: MUserId
|
||||
) {
|
||||
const { resolution, isPortraitMode, audioStream } = await videoArg.getMaxQualityFileInfo()
|
||||
const { resolution, isPortraitMode, audioStream } = await videoArg.probeMaxQualityFile()
|
||||
|
||||
// Maybe the video changed in database, refresh it
|
||||
const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid)
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
JobType,
|
||||
MoveObjectStoragePayload,
|
||||
RefreshPayload,
|
||||
VideoEditionPayload,
|
||||
VideoFileImportPayload,
|
||||
VideoImportPayload,
|
||||
VideoLiveEndingPayload,
|
||||
|
@ -31,6 +32,7 @@ import { refreshAPObject } from './handlers/activitypub-refresher'
|
|||
import { processActorKeys } from './handlers/actor-keys'
|
||||
import { processEmail } from './handlers/email'
|
||||
import { processMoveToObjectStorage } from './handlers/move-to-object-storage'
|
||||
import { processVideoEdition } from './handlers/video-edition'
|
||||
import { processVideoFileImport } from './handlers/video-file-import'
|
||||
import { processVideoImport } from './handlers/video-import'
|
||||
import { processVideoLiveEnding } from './handlers/video-live-ending'
|
||||
|
@ -53,6 +55,7 @@ type CreateJobArgument =
|
|||
{ type: 'actor-keys', payload: ActorKeysPayload } |
|
||||
{ type: 'video-redundancy', payload: VideoRedundancyPayload } |
|
||||
{ type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
|
||||
{ type: 'video-edition', payload: VideoEditionPayload } |
|
||||
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload }
|
||||
|
||||
export type CreateJobOptions = {
|
||||
|
@ -75,7 +78,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
|
|||
'video-live-ending': processVideoLiveEnding,
|
||||
'actor-keys': processActorKeys,
|
||||
'video-redundancy': processVideoRedundancy,
|
||||
'move-to-object-storage': processMoveToObjectStorage
|
||||
'move-to-object-storage': processMoveToObjectStorage,
|
||||
'video-edition': processVideoEdition
|
||||
}
|
||||
|
||||
const jobTypes: JobType[] = [
|
||||
|
@ -93,7 +97,8 @@ const jobTypes: JobType[] = [
|
|||
'video-redundancy',
|
||||
'actor-keys',
|
||||
'video-live-ending',
|
||||
'move-to-object-storage'
|
||||
'move-to-object-storage',
|
||||
'video-edition'
|
||||
]
|
||||
|
||||
class JobQueue {
|
||||
|
|
|
@ -5,10 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
|
|||
import {
|
||||
computeLowerResolutionsToTranscode,
|
||||
ffprobePromise,
|
||||
getVideoFileBitrate,
|
||||
getVideoFileFPS,
|
||||
getVideoFileResolution
|
||||
} from '@server/helpers/ffprobe-utils'
|
||||
getVideoStreamBitrate,
|
||||
getVideoStreamFPS,
|
||||
getVideoStreamDimensionsInfo
|
||||
} from '@server/helpers/ffmpeg'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
|
||||
import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
|
||||
|
@ -226,9 +226,9 @@ class LiveManager {
|
|||
const probe = await ffprobePromise(inputUrl)
|
||||
|
||||
const [ { resolution, ratio }, fps, bitrate ] = await Promise.all([
|
||||
getVideoFileResolution(inputUrl, probe),
|
||||
getVideoFileFPS(inputUrl, probe),
|
||||
getVideoFileBitrate(inputUrl, probe)
|
||||
getVideoStreamDimensionsInfo(inputUrl, probe),
|
||||
getVideoStreamFPS(inputUrl, probe),
|
||||
getVideoStreamBitrate(inputUrl, probe)
|
||||
])
|
||||
|
||||
logger.info(
|
||||
|
|
|
@ -5,14 +5,14 @@ import { FfmpegCommand } from 'fluent-ffmpeg'
|
|||
import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
|
||||
import { basename, join } from 'path'
|
||||
import { EventEmitter } from 'stream'
|
||||
import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils'
|
||||
import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg'
|
||||
import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
|
||||
import { CONFIG } from '@server/initializers/config'
|
||||
import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
|
||||
import { VideoFileModel } from '@server/models/video/video-file'
|
||||
import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
|
||||
import { getLiveDirectory } from '../../paths'
|
||||
import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles'
|
||||
import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles'
|
||||
import { isAbleToUploadVideo } from '../../user'
|
||||
import { LiveQuotaStore } from '../live-quota-store'
|
||||
import { LiveSegmentShaStore } from '../live-segment-sha-store'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import express from 'express'
|
||||
import { join } from 'path'
|
||||
import { ffprobePromise } from '@server/helpers/ffprobe-utils'
|
||||
import { ffprobePromise } from '@server/helpers/ffmpeg/ffprobe-utils'
|
||||
import { buildLogger } from '@server/helpers/logger'
|
||||
import { CONFIG } from '@server/initializers/config'
|
||||
import { WEBSERVER } from '@server/initializers/constants'
|
||||
|
|
|
@ -21,7 +21,7 @@ import {
|
|||
VideoPlaylistPrivacy,
|
||||
VideoPrivacy
|
||||
} from '@shared/models'
|
||||
import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles'
|
||||
import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles'
|
||||
import { buildPluginHelpers } from './plugin-helpers-builder'
|
||||
|
||||
export class RegisterHelpers {
|
||||
|
|
|
@ -8,7 +8,7 @@ import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuth
|
|||
import { Hooks } from './plugins/hooks'
|
||||
import { PluginManager } from './plugins/plugin-manager'
|
||||
import { getThemeOrDefault } from './plugins/theme-utils'
|
||||
import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
|
||||
import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles'
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -151,6 +151,9 @@ class ServerConfigManager {
|
|||
port: CONFIG.LIVE.RTMP.PORT
|
||||
}
|
||||
},
|
||||
videoEditor: {
|
||||
enabled: CONFIG.VIDEO_EDITOR.ENABLED
|
||||
},
|
||||
import: {
|
||||
videos: {
|
||||
http: {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { join } from 'path'
|
||||
import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
|
||||
import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
|
||||
import { generateImageFilename, processImage } from '../helpers/image-utils'
|
||||
import { ThumbnailType } from '@shared/models'
|
||||
import { generateImageFilename, generateImageFromVideoFile, processImage } from '../helpers/image-utils'
|
||||
import { downloadImage } from '../helpers/requests'
|
||||
import { CONFIG } from '../initializers/config'
|
||||
import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
|
||||
|
|
|
@ -2,8 +2,14 @@
|
|||
import { logger } from '@server/helpers/logger'
|
||||
import { getAverageBitrate, getMinLimitBitrate } from '@shared/core-utils'
|
||||
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, VideoResolution } from '../../../shared/models/videos'
|
||||
import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils'
|
||||
import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBitrate } from '../../helpers/ffprobe-utils'
|
||||
import {
|
||||
buildStreamSuffix,
|
||||
canDoQuickAudioTranscode,
|
||||
ffprobePromise,
|
||||
getAudioStream,
|
||||
getMaxAudioBitrate,
|
||||
resetSupportedEncoders
|
||||
} from '../../helpers/ffmpeg'
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -15,8 +21,14 @@ import { canDoQuickAudioTranscode, ffprobePromise, getAudioStream, getMaxAudioBi
|
|||
* * https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultX264VODOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOptionsBuilderParams) => {
|
||||
const { fps, inputRatio, inputBitrate, resolution } = options
|
||||
|
||||
// TODO: remove in 4.2, fps is not optional anymore
|
||||
if (!fps) return { outputOptions: [ ] }
|
||||
|
||||
const targetBitrate = getTargetBitrate({ inputBitrate, ratio: inputRatio, fps, resolution })
|
||||
|
@ -45,10 +57,10 @@ const defaultX264LiveOptionsBuilder: EncoderOptionsBuilder = (options: EncoderOp
|
|||
}
|
||||
}
|
||||
|
||||
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum }) => {
|
||||
const defaultAACOptionsBuilder: EncoderOptionsBuilder = async ({ input, streamNum, canCopyAudio }) => {
|
||||
const probe = await ffprobePromise(input)
|
||||
|
||||
if (await canDoQuickAudioTranscode(input, probe)) {
|
||||
if (canCopyAudio && await canDoQuickAudioTranscode(input, probe)) {
|
||||
logger.debug('Copy audio stream %s by AAC encoder.', input)
|
||||
return { copy: true, outputOptions: [ ] }
|
||||
}
|
||||
|
@ -75,7 +87,10 @@ const defaultLibFDKAACVODOptionsBuilder: EncoderOptionsBuilder = ({ streamNum })
|
|||
return { outputOptions: [ buildStreamSuffix('-q:a', streamNum), '5' ] }
|
||||
}
|
||||
|
||||
// Used to get and update available encoders
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile manager to get and change default profiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class VideoTranscodingProfilesManager {
|
||||
private static instance: VideoTranscodingProfilesManager
|
||||
|
|
@ -6,8 +6,15 @@ import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
|||
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||
import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
|
||||
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
|
||||
import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils'
|
||||
import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils'
|
||||
import {
|
||||
canDoQuickTranscode,
|
||||
getVideoStreamDuration,
|
||||
buildFileMetadata,
|
||||
getVideoStreamFPS,
|
||||
transcodeVOD,
|
||||
TranscodeVODOptions,
|
||||
TranscodeVODOptionsType
|
||||
} from '../../helpers/ffmpeg'
|
||||
import { CONFIG } from '../../initializers/config'
|
||||
import { P2P_MEDIA_LOADER_PEER_VERSION } from '../../initializers/constants'
|
||||
import { VideoFileModel } from '../../models/video/video-file'
|
||||
|
@ -21,7 +28,7 @@ import {
|
|||
getHlsResolutionPlaylistFilename
|
||||
} from '../paths'
|
||||
import { VideoPathManager } from '../video-path-manager'
|
||||
import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
|
||||
import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -38,13 +45,13 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
|
|||
return VideoPathManager.Instance.makeAvailableVideoFile(inputVideoFile.withVideoOrPlaylist(video), async videoInputPath => {
|
||||
const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||
|
||||
const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath)
|
||||
const transcodeType: TranscodeVODOptionsType = await canDoQuickTranscode(videoInputPath)
|
||||
? 'quick-transcode'
|
||||
: 'video'
|
||||
|
||||
const resolution = toEven(inputVideoFile.resolution)
|
||||
|
||||
const transcodeOptions: TranscodeOptions = {
|
||||
const transcodeOptions: TranscodeVODOptions = {
|
||||
type: transcodeType,
|
||||
|
||||
inputPath: videoInputPath,
|
||||
|
@ -59,7 +66,7 @@ function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile: MVid
|
|||
}
|
||||
|
||||
// Could be very long!
|
||||
await transcode(transcodeOptions)
|
||||
await transcodeVOD(transcodeOptions)
|
||||
|
||||
// Important to do this before getVideoFilename() to take in account the new filename
|
||||
inputVideoFile.extname = newExtname
|
||||
|
@ -121,7 +128,7 @@ function transcodeNewWebTorrentResolution (video: MVideoFullLight, resolution: V
|
|||
job
|
||||
}
|
||||
|
||||
await transcode(transcodeOptions)
|
||||
await transcodeVOD(transcodeOptions)
|
||||
|
||||
return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
|
||||
})
|
||||
|
@ -158,7 +165,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
|
|||
}
|
||||
|
||||
try {
|
||||
await transcode(transcodeOptions)
|
||||
await transcodeVOD(transcodeOptions)
|
||||
|
||||
await remove(audioInputPath)
|
||||
await remove(tmpPreviewPath)
|
||||
|
@ -175,7 +182,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
|
|||
const videoOutputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, inputVideoFile)
|
||||
// ffmpeg generated a new video file, so update the video duration
|
||||
// See https://trac.ffmpeg.org/ticket/5456
|
||||
video.duration = await getDurationFromVideoFile(videoTranscodedPath)
|
||||
video.duration = await getVideoStreamDuration(videoTranscodedPath)
|
||||
await video.save()
|
||||
|
||||
return onWebTorrentVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
|
||||
|
@ -239,8 +246,8 @@ async function onWebTorrentVideoFileTranscoding (
|
|||
outputPath: string
|
||||
) {
|
||||
const stats = await stat(transcodingPath)
|
||||
const fps = await getVideoFileFPS(transcodingPath)
|
||||
const metadata = await getMetadataFromFile(transcodingPath)
|
||||
const fps = await getVideoStreamFPS(transcodingPath)
|
||||
const metadata = await buildFileMetadata(transcodingPath)
|
||||
|
||||
await move(transcodingPath, outputPath, { overwrite: true })
|
||||
|
||||
|
@ -299,7 +306,7 @@ async function generateHlsPlaylistCommon (options: {
|
|||
job
|
||||
}
|
||||
|
||||
await transcode(transcodeOptions)
|
||||
await transcodeVOD(transcodeOptions)
|
||||
|
||||
// Create or update the playlist
|
||||
const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
|
||||
|
@ -344,8 +351,8 @@ async function generateHlsPlaylistCommon (options: {
|
|||
const stats = await stat(videoFilePath)
|
||||
|
||||
newVideoFile.size = stats.size
|
||||
newVideoFile.fps = await getVideoFileFPS(videoFilePath)
|
||||
newVideoFile.metadata = await getMetadataFromFile(videoFilePath)
|
||||
newVideoFile.fps = await getVideoStreamFPS(videoFilePath)
|
||||
newVideoFile.metadata = await buildFileMetadata(videoFilePath)
|
||||
|
||||
await createTorrentAndSetInfoHash(playlist, newVideoFile)
|
||||
|
|
@ -19,6 +19,7 @@ import { buildActorInstance } from './local-actor'
|
|||
import { Redis } from './redis'
|
||||
import { createLocalVideoChannel } from './video-channel'
|
||||
import { createWatchLaterPlaylist } from './video-playlist'
|
||||
import { logger } from '@server/helpers/logger'
|
||||
|
||||
type ChannelNames = { name: string, displayName: string }
|
||||
|
||||
|
@ -159,6 +160,11 @@ async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
|
|||
const uploadedTotal = newVideoSize + totalBytes
|
||||
const uploadedDaily = newVideoSize + totalBytesDaily
|
||||
|
||||
logger.debug(
|
||||
'Check user %d quota to upload another video.', userId,
|
||||
{ totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, newVideoSize }
|
||||
)
|
||||
|
||||
if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
|
||||
if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId) {
|
||||
async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoFile, user: MUserId, isNewVideo = true) {
|
||||
let dataInput: VideoTranscodingPayload
|
||||
|
||||
if (videoFile.isAudio()) {
|
||||
|
@ -90,13 +90,13 @@ async function addOptimizeOrMergeAudioJob (video: MVideoUUID, videoFile: MVideoF
|
|||
resolution: DEFAULT_AUDIO_RESOLUTION,
|
||||
videoUUID: video.uuid,
|
||||
createHLSIfNeeded: true,
|
||||
isNewVideo: true
|
||||
isNewVideo
|
||||
}
|
||||
} else {
|
||||
dataInput = {
|
||||
type: 'optimize-to-webtorrent',
|
||||
videoUUID: video.uuid,
|
||||
isNewVideo: true
|
||||
isNewVideo
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -57,6 +57,8 @@ const customConfigUpdateValidator = [
|
|||
body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
|
||||
body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
|
||||
|
||||
body('videoEditor.enabled').isBoolean().withMessage('Should have a valid video editor enabled boolean'),
|
||||
|
||||
body('import.videos.concurrency').isInt({ min: 0 }).withMessage('Should have a valid import concurrency number'),
|
||||
body('import.videos.http.enabled').isBoolean().withMessage('Should have a valid import video http enabled boolean'),
|
||||
body('import.videos.torrent.enabled').isBoolean().withMessage('Should have a valid import video torrent enabled boolean'),
|
||||
|
@ -104,6 +106,7 @@ const customConfigUpdateValidator = [
|
|||
if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
|
||||
if (!checkInvalidTranscodingConfig(req.body, res)) return
|
||||
if (!checkInvalidLiveConfig(req.body, res)) return
|
||||
if (!checkInvalidVideoEditorConfig(req.body, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
|
@ -159,3 +162,14 @@ function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Respon
|
|||
|
||||
return true
|
||||
}
|
||||
|
||||
function checkInvalidVideoEditorConfig (customConfig: CustomConfig, res: express.Response) {
|
||||
if (customConfig.videoEditor.enabled === false) return true
|
||||
|
||||
if (customConfig.videoEditor.enabled === true && customConfig.transcoding.enabled === false) {
|
||||
res.fail({ message: 'You cannot enable video editor if transcoding is not enabled' })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ function areValidationErrors (req: express.Request, res: express.Response) {
|
|||
|
||||
if (!errors.isEmpty()) {
|
||||
logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
|
||||
|
||||
res.fail({
|
||||
message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
|
||||
instance: req.originalUrl,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Request, Response } from 'express'
|
||||
import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
|
||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||
import { authenticatePromiseIfNeeded } from '@server/middlewares/auth'
|
||||
import { VideoModel } from '@server/models/video/video'
|
||||
import { VideoChannelModel } from '@server/models/video/video-channel'
|
||||
|
@ -7,6 +8,7 @@ import { VideoFileModel } from '@server/models/video/video-file'
|
|||
import {
|
||||
MUser,
|
||||
MUserAccountId,
|
||||
MUserId,
|
||||
MVideo,
|
||||
MVideoAccountLight,
|
||||
MVideoFormattableDetails,
|
||||
|
@ -16,7 +18,7 @@ import {
|
|||
MVideoThumbnail,
|
||||
MVideoWithRights
|
||||
} from '@server/types/models'
|
||||
import { HttpStatusCode, UserRight } from '@shared/models'
|
||||
import { HttpStatusCode, ServerErrorCode, UserRight } from '@shared/models'
|
||||
|
||||
async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
|
||||
const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
|
||||
|
@ -108,6 +110,11 @@ async function checkCanSeePrivateVideo (req: Request, res: Response, video: MVid
|
|||
|
||||
// Only the owner or a user that have blocklist rights can see the video
|
||||
if (!user || !user.canGetVideo(video)) {
|
||||
res.fail({
|
||||
status: HttpStatusCode.FORBIDDEN_403,
|
||||
message: 'Cannot fetch information of private/internal/blocklisted video'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -139,13 +146,28 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
|
|||
return true
|
||||
}
|
||||
|
||||
async function checkUserQuota (user: MUserId, videoFileSize: number, res: Response) {
|
||||
if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
|
||||
res.fail({
|
||||
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
||||
message: 'The user video quota is exceeded with this video.',
|
||||
type: ServerErrorCode.QUOTA_REACHED
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
doesVideoChannelOfAccountExist,
|
||||
doesVideoExist,
|
||||
doesVideoFileOfVideoExist,
|
||||
|
||||
checkUserCanManageVideo,
|
||||
checkCanSeeVideoIfPrivate,
|
||||
checkCanSeePrivateVideo
|
||||
checkCanSeePrivateVideo,
|
||||
checkUserQuota
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ export * from './video-blacklist'
|
|||
export * from './video-captions'
|
||||
export * from './video-channels'
|
||||
export * from './video-comments'
|
||||
export * from './video-editor'
|
||||
export * from './video-files'
|
||||
export * from './video-imports'
|
||||
export * from './video-live'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import express from 'express'
|
||||
import { body, param } from 'express-validator'
|
||||
import { HttpStatusCode, UserRight } from '@shared/models'
|
||||
import { UserRight } from '@shared/models'
|
||||
import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
|
||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
|
@ -74,13 +74,7 @@ const listVideoCaptionsValidator = [
|
|||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||
|
||||
const video = res.locals.onlyVideo
|
||||
|
||||
if (!await checkCanSeeVideoIfPrivate(req, res, video)) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.FORBIDDEN_403,
|
||||
message: 'Cannot list captions of private/internal/blocklisted video'
|
||||
})
|
||||
}
|
||||
if (!await checkCanSeeVideoIfPrivate(req, res, video)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
|
|
|
@ -54,12 +54,7 @@ const listVideoCommentThreadsValidator = [
|
|||
if (areValidationErrors(req, res)) return
|
||||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||
|
||||
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.FORBIDDEN_403,
|
||||
message: 'Cannot list comments of private/internal/blocklisted video'
|
||||
})
|
||||
}
|
||||
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
|
@ -78,12 +73,7 @@ const listVideoThreadCommentsValidator = [
|
|||
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
|
||||
if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
|
||||
|
||||
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) {
|
||||
return res.fail({
|
||||
status: HttpStatusCode.FORBIDDEN_403,
|
||||
message: 'Cannot list threads of private/internal/blocklisted video'
|
||||
})
|
||||
}
|
||||
if (!await checkCanSeeVideoIfPrivate(req, res, res.locals.onlyVideo)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
|
|
|
@ -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 { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
|
||||
import { logger } from '@server/helpers/logger'
|
||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||
import { AccountModel } from '@server/models/account/account'
|
||||
import { MVideoWithAllFiles } from '@server/types/models'
|
||||
import {
|
||||
HttpStatusCode,
|
||||
ServerErrorCode,
|
||||
UserRight,
|
||||
VideoChangeOwnershipAccept,
|
||||
VideoChangeOwnershipStatus,
|
||||
VideoState
|
||||
} from '@shared/models'
|
||||
import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models'
|
||||
import {
|
||||
areValidationErrors,
|
||||
checkUserCanManageVideo,
|
||||
checkUserQuota,
|
||||
doesChangeVideoOwnershipExist,
|
||||
doesVideoChannelOfAccountExist,
|
||||
doesVideoExist,
|
||||
|
@ -113,15 +106,7 @@ async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response)
|
|||
|
||||
const user = res.locals.oauth.token.User
|
||||
|
||||
if (!await isAbleToUploadVideo(user.id, video.getMaxQualityFile().size)) {
|
||||
res.fail({
|
||||
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
||||
message: 'The user video quota is exceeded with this video.',
|
||||
type: ServerErrorCode.QUOTA_REACHED
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
if (!await checkUserQuota(user, video.getMaxQualityFile().size, res)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ import {
|
|||
isVideoPlaylistTimestampValid,
|
||||
isVideoPlaylistTypeValid
|
||||
} from '../../../helpers/custom-validators/video-playlists'
|
||||
import { isVideoImage } from '../../../helpers/custom-validators/videos'
|
||||
import { isVideoImageValid } from '../../../helpers/custom-validators/videos'
|
||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
|
||||
|
@ -390,7 +390,7 @@ export {
|
|||
function getCommonPlaylistEditAttributes () {
|
||||
return [
|
||||
body('thumbnailfile')
|
||||
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile'))
|
||||
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
|
||||
.withMessage(
|
||||
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
|
||||
CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
|
||||
|
|
|
@ -3,7 +3,6 @@ import { body, header, param, query, ValidationChain } from 'express-validator'
|
|||
import { isTestInstance } from '@server/helpers/core-utils'
|
||||
import { getResumableUploadPath } from '@server/helpers/upload'
|
||||
import { Redis } from '@server/lib/redis'
|
||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||
import { getServerActor } from '@server/models/application/application'
|
||||
import { ExpressPromiseHandler } from '@server/types/express-handler'
|
||||
import { MUserAccountId, MVideoFullLight } from '@server/types/models'
|
||||
|
@ -13,7 +12,7 @@ import {
|
|||
exists,
|
||||
isBooleanValid,
|
||||
isDateValid,
|
||||
isFileFieldValid,
|
||||
isFileValid,
|
||||
isIdValid,
|
||||
isUUIDValid,
|
||||
toArray,
|
||||
|
@ -23,24 +22,24 @@ import {
|
|||
} from '../../../helpers/custom-validators/misc'
|
||||
import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
|
||||
import {
|
||||
areVideoTagsValid,
|
||||
isScheduleVideoUpdatePrivacyValid,
|
||||
isVideoCategoryValid,
|
||||
isVideoDescriptionValid,
|
||||
isVideoFileMimeTypeValid,
|
||||
isVideoFileSizeValid,
|
||||
isVideoFilterValid,
|
||||
isVideoImage,
|
||||
isVideoImageValid,
|
||||
isVideoIncludeValid,
|
||||
isVideoLanguageValid,
|
||||
isVideoLicenceValid,
|
||||
isVideoNameValid,
|
||||
isVideoOriginallyPublishedAtValid,
|
||||
isVideoPrivacyValid,
|
||||
isVideoSupportValid,
|
||||
isVideoTagsValid
|
||||
isVideoSupportValid
|
||||
} from '../../../helpers/custom-validators/videos'
|
||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
|
||||
import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { deleteFileAndCatch } from '../../../helpers/utils'
|
||||
import { getVideoWithAttributes } from '../../../helpers/video'
|
||||
|
@ -53,6 +52,7 @@ import {
|
|||
areValidationErrors,
|
||||
checkCanSeePrivateVideo,
|
||||
checkUserCanManageVideo,
|
||||
checkUserQuota,
|
||||
doesVideoChannelOfAccountExist,
|
||||
doesVideoExist,
|
||||
doesVideoFileOfVideoExist,
|
||||
|
@ -61,7 +61,7 @@ import {
|
|||
|
||||
const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
|
||||
body('videofile')
|
||||
.custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
|
||||
.custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
|
||||
.withMessage('Should have a file'),
|
||||
body('name')
|
||||
.trim()
|
||||
|
@ -299,12 +299,11 @@ const videosCustomGetValidator = (
|
|||
|
||||
// Video private or blacklisted
|
||||
if (video.requiresAuth()) {
|
||||
if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) return next()
|
||||
if (await checkCanSeePrivateVideo(req, res, video, authenticateInQuery)) {
|
||||
return next()
|
||||
}
|
||||
|
||||
return res.fail({
|
||||
status: HttpStatusCode.FORBIDDEN_403,
|
||||
message: 'Cannot get this private/internal or blocklisted video'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Video is public, anyone can access it
|
||||
|
@ -375,12 +374,12 @@ const videosOverviewValidator = [
|
|||
function getCommonVideoEditAttributes () {
|
||||
return [
|
||||
body('thumbnailfile')
|
||||
.custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
|
||||
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
|
||||
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
|
||||
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
|
||||
),
|
||||
body('previewfile')
|
||||
.custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
|
||||
.custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
|
||||
'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
|
||||
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
|
||||
),
|
||||
|
@ -420,7 +419,7 @@ function getCommonVideoEditAttributes () {
|
|||
body('tags')
|
||||
.optional()
|
||||
.customSanitizer(toValueOrNull)
|
||||
.custom(isVideoTagsValid)
|
||||
.custom(areVideoTagsValid)
|
||||
.withMessage(
|
||||
`Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
|
||||
`${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
|
||||
|
@ -612,14 +611,7 @@ async function commonVideoChecksPass (parameters: {
|
|||
return false
|
||||
}
|
||||
|
||||
if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
|
||||
res.fail({
|
||||
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
|
||||
message: 'The user video quota is exceeded with this video.',
|
||||
type: ServerErrorCode.QUOTA_REACHED
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (await checkUserQuota(user, videoFileSize, res) === false) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
@ -654,7 +646,7 @@ export async function isVideoAccepted (
|
|||
}
|
||||
|
||||
async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
|
||||
const duration: number = await getDurationFromVideoFile(videoFile.path)
|
||||
const duration: number = await getVideoStreamDuration(videoFile.path)
|
||||
|
||||
if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
|
||||
|
||||
|
|
|
@ -61,7 +61,7 @@ import {
|
|||
isVideoStateValid,
|
||||
isVideoSupportValid
|
||||
} from '../../helpers/custom-validators/videos'
|
||||
import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
|
||||
import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { CONFIG } from '../../initializers/config'
|
||||
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
|
||||
|
@ -1683,7 +1683,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
|||
return peertubeTruncate(this.description, { length: maxLength })
|
||||
}
|
||||
|
||||
getMaxQualityFileInfo () {
|
||||
probeMaxQualityFile () {
|
||||
const file = this.getMaxQualityFile()
|
||||
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
|
||||
|
||||
|
@ -1695,7 +1695,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
|||
return {
|
||||
audioStream,
|
||||
|
||||
...await getVideoFileResolution(originalFilePath, probe)
|
||||
...await getVideoStreamDimensionsInfo(originalFilePath, probe)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -25,12 +25,16 @@ describe('Test AP refresher', function () {
|
|||
before(async function () {
|
||||
this.timeout(60000)
|
||||
|
||||
servers = await createMultipleServers(2, { transcoding: { enabled: false } })
|
||||
servers = await createMultipleServers(2)
|
||||
|
||||
// Get the access tokens
|
||||
await setAccessTokensToServers(servers)
|
||||
await setDefaultVideoChannel(servers)
|
||||
|
||||
for (const server of servers) {
|
||||
await server.config.disableTranscoding()
|
||||
}
|
||||
|
||||
{
|
||||
videoUUID1 = (await servers[1].videos.quickUpload({ name: 'video1' })).uuid
|
||||
videoUUID2 = (await servers[1].videos.quickUpload({ name: 'video2' })).uuid
|
||||
|
|
|
@ -145,6 +145,9 @@ describe('Test config API validators', function () {
|
|||
}
|
||||
}
|
||||
},
|
||||
videoEditor: {
|
||||
enabled: true
|
||||
},
|
||||
import: {
|
||||
videos: {
|
||||
concurrency: 1,
|
||||
|
|
|
@ -25,6 +25,7 @@ import './video-blacklist'
|
|||
import './video-captions'
|
||||
import './video-channels'
|
||||
import './video-comments'
|
||||
import './video-editor'
|
||||
import './video-imports'
|
||||
import './video-playlists'
|
||||
import './videos'
|
||||
|
|
|
@ -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 * as chai from 'chai'
|
||||
import { basename, join } from 'path'
|
||||
import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils'
|
||||
import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
|
||||
import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
|
||||
import { wait } from '@shared/core-utils'
|
||||
import {
|
||||
|
@ -562,7 +562,7 @@ describe('Test live', function () {
|
|||
const segmentPath = servers[0].servers.buildDirectory(join('streaming-playlists', 'hls', video.uuid, filename))
|
||||
|
||||
const probe = await ffprobePromise(segmentPath)
|
||||
const videoStream = await getVideoStreamFromFile(segmentPath, probe)
|
||||
const videoStream = await getVideoStream(segmentPath, probe)
|
||||
|
||||
expect(probe.format.bit_rate).to.be.below(maxBitrateLimits[videoStream.height])
|
||||
expect(probe.format.bit_rate).to.be.at.least(minBitrateLimits[videoStream.height])
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue