Add basic video editor support

This commit is contained in:
Chocobozzz 2022-02-11 10:51:33 +01:00 committed by Chocobozzz
parent a24bf4dc65
commit c729caf6cc
130 changed files with 3969 additions and 1353 deletions

View File

@ -197,6 +197,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
resolutions: {}
}
},
videoEditor: {
enabled: null
},
autoBlacklist: {
videos: {
ofUsers: {

View File

@ -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>

View File

@ -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)
}
})
}
}

View File

@ -1,5 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
my-embed {
display: block;
max-width: 500px;

View File

@ -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),

View File

@ -0,0 +1,2 @@
export * from './video-editor-edit.component'
export * from './video-editor-edit.resolver'

View File

@ -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>

View File

@ -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;
}

View File

@ -0,0 +1,202 @@
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfirmService, Notifier, ServerService } from '@app/core'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { Video, VideoDetails } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { secondsToTime } from '@shared/core-utils'
import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models'
import { VideoEditorService } from '../shared'
@Component({
selector: 'my-video-editor-edit',
templateUrl: './video-editor-edit.component.html',
styleUrls: [ './video-editor-edit.component.scss' ]
})
export class VideoEditorEditComponent extends FormReactive implements OnInit {
isRunningEdition = false
video: VideoDetails
constructor (
protected formValidatorService: FormValidatorService,
private serverService: ServerService,
private notifier: Notifier,
private router: Router,
private route: ActivatedRoute,
private videoEditorService: VideoEditorService,
private loadingBar: LoadingBarService,
private confirmService: ConfirmService
) {
super()
}
ngOnInit () {
this.video = this.route.snapshot.data.video
const defaultValues = {
cut: {
start: 0,
end: this.video.duration
}
}
this.buildForm({
cut: {
start: null,
end: null
},
'add-intro': {
file: null
},
'add-outro': {
file: null
},
'add-watermark': {
file: null
}
}, defaultValues)
}
get videoExtensions () {
return this.serverService.getHTMLConfig().video.file.extensions
}
get imageExtensions () {
return this.serverService.getHTMLConfig().video.image.extensions
}
async runEdition () {
if (this.isRunningEdition) return
const title = $localize`Are you sure you want to edit "${this.video.name}"?`
const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('')
// eslint-disable-next-line max-len
const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` +
$localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>`
if (await this.confirmService.confirm(confirmHTML, title) !== true) return
this.isRunningEdition = true
const tasks = this.buildTasks()
this.loadingBar.useRef().start()
return this.videoEditorService.editVideo(this.video.uuid, tasks)
.subscribe({
next: () => {
this.notifier.success($localize`Video updated.`)
this.router.navigateByUrl(Video.buildWatchUrl(this.video))
},
error: err => {
this.loadingBar.useRef().complete()
this.isRunningEdition = false
this.notifier.error(err.message)
console.error(err)
}
})
}
getIntroOutroTooltip () {
return $localize`(extensions: ${this.videoExtensions.join(', ')})`
}
getWatermarkTooltip () {
return $localize`(extensions: ${this.imageExtensions.join(', ')})`
}
noEdition () {
return this.buildTasks().length === 0
}
getTasksSummary () {
const tasks = this.buildTasks()
return tasks.map(t => {
if (t.name === 'add-intro') {
return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video`
}
if (t.name === 'add-outro') {
return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video`
}
if (t.name === 'add-watermark') {
return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video`
}
if (t.name === 'cut') {
const { start, end } = t.options
if (start !== undefined && end !== undefined) {
return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}`
}
if (start !== undefined) {
return $localize`Video will begin at ${secondsToTime(start)}`
}
if (end !== undefined) {
return $localize`Video will stop at ${secondsToTime(end)}`
}
}
return ''
})
}
private getFilename (obj: any) {
return obj.name
}
private buildTasks () {
const tasks: VideoEditorTask[] = []
const value = this.form.value
const cut = value['cut']
if (cut['start'] !== 0 || cut['end'] !== this.video.duration) {
const options: VideoEditorTaskCut['options'] = {}
if (cut['start'] !== 0) options.start = cut['start']
if (cut['end'] !== this.video.duration) options.end = cut['end']
tasks.push({
name: 'cut',
options
})
}
if (value['add-intro']?.['file']) {
tasks.push({
name: 'add-intro',
options: {
file: value['add-intro']['file']
}
})
}
if (value['add-outro']?.['file']) {
tasks.push({
name: 'add-outro',
options: {
file: value['add-outro']['file']
}
})
}
if (value['add-watermark']?.['file']) {
tasks.push({
name: 'add-watermark',
options: {
file: value['add-watermark']['file']
}
})
}
return tasks
}
}

View File

@ -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 })
}
}

View File

@ -0,0 +1 @@
export * from './video-editor.module'

View File

@ -0,0 +1 @@
export * from './video-editor.service'

View File

@ -0,0 +1,28 @@
import { catchError } from 'rxjs'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { objectToFormData } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main'
import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models'
@Injectable()
export class VideoEditorService {
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) {}
editVideo (videoId: number | string, tasks: VideoEditorTask[]) {
const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit'
const body: VideoEditorCreateEdition = {
tasks
}
const data = objectToFormData(body)
return this.authHttp.post(url, data)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View File

@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { VideoEditorEditResolver } from './edit'
import { VideoEditorEditComponent } from './edit/video-editor-edit.component'
const videoEditorRoutes: Routes = [
{
path: '',
children: [
{
path: 'edit/:videoId',
component: VideoEditorEditComponent,
data: {
meta: {
title: $localize`Edit video`
}
},
resolve: {
video: VideoEditorEditResolver
}
}
]
}
]
@NgModule({
imports: [ RouterModule.forChild(videoEditorRoutes) ],
exports: [ RouterModule ]
})
export class VideoEditorRoutingModule {}

View File

@ -0,0 +1,27 @@
import { NgModule } from '@angular/core'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedMainModule } from '@app/shared/shared-main'
import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit'
import { VideoEditorService } from './shared'
import { VideoEditorRoutingModule } from './video-editor-routing.module'
@NgModule({
imports: [
VideoEditorRoutingModule,
SharedMainModule,
SharedFormModule
],
declarations: [
VideoEditorEditComponent
],
exports: [],
providers: [
VideoEditorService,
VideoEditorEditResolver
]
})
export class VideoEditorModule { }

View File

@ -35,6 +35,7 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
playlist: false,
download: true,
update: true,
editor: true,
blacklist: true,
delete: true,
report: true,

View File

@ -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>

View File

@ -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
}

View File

@ -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 => {

View File

@ -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()

View File

@ -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 ]

View File

@ -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>

View File

@ -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);
}
}
}

View File

@ -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()

View File

@ -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(),

View File

@ -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 ''
}

View File

@ -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:

View File

@ -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:

View File

@ -37,6 +37,9 @@ signup:
transcoding:
enabled: false
video_editor:
enabled: false
live:
rtmp:
port: 1936

View File

@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
video_editor:
enabled: false

View File

@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
video_editor:
enabled: false

View File

@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
video_editor:
enabled: false

View File

@ -30,3 +30,6 @@ admin:
transcoding:
enabled: false
video_editor:
enabled: false

View File

@ -164,3 +164,6 @@ views:
local_buffer_update_interval: '5 seconds'
ip_view_expiration: '1 second'
video_editor:
enabled: true

View File

@ -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'

View File

@ -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)

View File

@ -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)

View File

@ -256,6 +256,9 @@ function customConfig (): CustomConfig {
}
}
},
videoEditor: {
enabled: CONFIG.VIDEO_EDITOR.ENABLED
},
import: {
videos: {
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,

View File

@ -0,0 +1,120 @@
import express from 'express'
import { createAnyReqFiles } from '@server/helpers/express-utils'
import { CONFIG } from '@server/initializers/config'
import { MIMETYPES } from '@server/initializers/constants'
import { JobQueue } from '@server/lib/job-queue'
import { buildTaskFileFieldname, getTaskFile } from '@server/lib/video-editor'
import {
HttpStatusCode,
VideoEditionTaskPayload,
VideoEditorCreateEdition,
VideoEditorTask,
VideoEditorTaskCut,
VideoEditorTaskIntro,
VideoEditorTaskOutro,
VideoEditorTaskWatermark,
VideoState
} from '@shared/models'
import { asyncMiddleware, authenticate, videosEditorAddEditionValidator } from '../../../middlewares'
const editorRouter = express.Router()
const tasksFiles = createAnyReqFiles(
MIMETYPES.VIDEO.MIMETYPE_EXT,
CONFIG.STORAGE.TMP_DIR,
(req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
const body = req.body as VideoEditorCreateEdition
// Fetch array element
const matches = file.fieldname.match(/tasks\[(\d+)\]/)
if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
const indice = parseInt(matches[1])
const task = body.tasks[indice]
if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
if (
[ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
file.fieldname === buildTaskFileFieldname(indice)
) {
return cb(null, true)
}
return cb(null, false)
}
)
editorRouter.post('/:videoId/editor/edit',
authenticate,
tasksFiles,
asyncMiddleware(videosEditorAddEditionValidator),
asyncMiddleware(createEditionTasks)
)
// ---------------------------------------------------------------------------
export {
editorRouter
}
// ---------------------------------------------------------------------------
async function createEditionTasks (req: express.Request, res: express.Response) {
const files = req.files as Express.Multer.File[]
const body = req.body as VideoEditorCreateEdition
const video = res.locals.videoAll
video.state = VideoState.TO_EDIT
await video.save()
const payload = {
videoUUID: video.uuid,
tasks: body.tasks.map((t, i) => buildTaskPayload(t, i, files))
}
JobQueue.Instance.createJob({ type: 'video-edition', payload })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
const taskPayloadBuilders: {
[id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => VideoEditionTaskPayload
} = {
'add-intro': buildIntroOutroTask,
'add-outro': buildIntroOutroTask,
'cut': buildCutTask,
'add-watermark': buildWatermarkTask
}
function buildTaskPayload (task: VideoEditorTask, indice: number, files: Express.Multer.File[]): VideoEditionTaskPayload {
return taskPayloadBuilders[task.name](task, indice, files)
}
function buildIntroOutroTask (task: VideoEditorTaskIntro | VideoEditorTaskOutro, indice: number, files: Express.Multer.File[]) {
return {
name: task.name,
options: {
file: getTaskFile(files, indice).path
}
}
}
function buildCutTask (task: VideoEditorTaskCut) {
return {
name: task.name,
options: {
start: task.options.start,
end: task.options.end
}
}
}
function buildWatermarkTask (task: VideoEditorTaskWatermark, indice: number, files: Express.Multer.File[]) {
return {
name: task.name,
options: {
file: getTaskFile(files, indice).path
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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
})
}
// ---------------------------------------------------------------------------

View File

@ -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
}

View File

@ -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) {

View File

@ -0,0 +1,52 @@
import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
import { buildTaskFileFieldname } from '@server/lib/video-editor'
import { VideoEditorTask } from '@shared/models'
import { isArray } from './misc'
import { isVideoFileMimeTypeValid, isVideoImageValid } from './videos'
function isValidEditorTasksArray (tasks: any) {
if (!isArray(tasks)) return false
return tasks.length >= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.min &&
tasks.length <= CONSTRAINTS_FIELDS.VIDEO_EDITOR.TASKS.max
}
function isEditorCutTaskValid (task: VideoEditorTask) {
if (task.name !== 'cut') return false
if (!task.options) return false
const { start, end } = task.options
if (!start && !end) return false
if (start && !validator.isInt(start + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
if (end && !validator.isInt(end + '', CONSTRAINTS_FIELDS.VIDEO_EDITOR.CUT_TIME)) return false
if (!start || !end) return true
return parseInt(start + '') < parseInt(end + '')
}
function isEditorTaskAddIntroOutroValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
return (task.name === 'add-intro' || task.name === 'add-outro') &&
file && isVideoFileMimeTypeValid([ file ], null)
}
function isEditorTaskAddWatermarkValid (task: VideoEditorTask, indice: number, files: Express.Multer.File[]) {
const file = files.find(f => f.fieldname === buildTaskFileFieldname(indice, 'file'))
return task.name === 'add-watermark' &&
file && isVideoImageValid([ file ], null, true)
}
// ---------------------------------------------------------------------------
export {
isValidEditorTasksArray,
isEditorCutTaskValid,
isEditorTaskAddIntroOutroValid,
isEditorTaskAddWatermarkValid
}

View File

@ -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
})
}
// ---------------------------------------------------------------------------

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -0,0 +1,114 @@
import { Job } from 'bull'
import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
import { execPromise } from '@server/helpers/core-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { CONFIG } from '@server/initializers/config'
import { FFMPEG_NICE } from '@server/initializers/constants'
import { EncoderOptions } from '@shared/models'
const lTags = loggerTagsFactory('ffmpeg')
type StreamType = 'audio' | 'video'
function getFFmpeg (input: string, type: 'live' | 'vod') {
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
const command = ffmpeg(input, {
niceness: type === 'live' ? FFMPEG_NICE.LIVE : FFMPEG_NICE.VOD,
cwd: CONFIG.STORAGE.TMP_DIR
})
const threads = type === 'live'
? CONFIG.LIVE.TRANSCODING.THREADS
: CONFIG.TRANSCODING.THREADS
if (threads > 0) {
// If we don't set any threads ffmpeg will chose automatically
command.outputOption('-threads ' + threads)
}
return command
}
function getFFmpegVersion () {
return new Promise<string>((res, rej) => {
(ffmpeg() as any)._getFfmpegPath((err, ffmpegPath) => {
if (err) return rej(err)
if (!ffmpegPath) return rej(new Error('Could not find ffmpeg path'))
return execPromise(`${ffmpegPath} -version`)
.then(stdout => {
const parsed = stdout.match(/ffmpeg version .?(\d+\.\d+(\.\d+)?)/)
if (!parsed || !parsed[1]) return rej(new Error(`Could not find ffmpeg version in ${stdout}`))
// Fix ffmpeg version that does not include patch version (4.4 for example)
let version = parsed[1]
if (version.match(/^\d+\.\d+$/)) {
version += '.0'
}
return res(version)
})
.catch(err => rej(err))
})
})
}
async function runCommand (options: {
command: FfmpegCommand
silent?: boolean // false by default
job?: Job
}) {
const { command, silent = false, job } = options
return new Promise<void>((res, rej) => {
let shellCommand: string
command.on('start', cmdline => { shellCommand = cmdline })
command.on('error', (err, stdout, stderr) => {
if (silent !== true) logger.error('Error in ffmpeg.', { stdout, stderr, shellCommand, ...lTags() })
rej(err)
})
command.on('end', (stdout, stderr) => {
logger.debug('FFmpeg command ended.', { stdout, stderr, shellCommand, ...lTags() })
res()
})
if (job) {
command.on('progress', progress => {
if (!progress.percent) return
job.progress(Math.round(progress.percent))
.catch(err => logger.warn('Cannot set ffmpeg job progress.', { err, ...lTags() }))
})
}
command.run()
})
}
function buildStreamSuffix (base: string, streamNum?: number) {
if (streamNum !== undefined) {
return `${base}:${streamNum}`
}
return base
}
function getScaleFilter (options: EncoderOptions): string {
if (options.scaleFilter) return options.scaleFilter.name
return 'scale'
}
export {
getFFmpeg,
getFFmpegVersion,
runCommand,
StreamType,
buildStreamSuffix,
getScaleFilter
}

View File

@ -0,0 +1,242 @@
import { FilterSpecification } from 'fluent-ffmpeg'
import { VIDEO_FILTERS } from '@server/initializers/constants'
import { AvailableEncoders } from '@shared/models'
import { logger, loggerTagsFactory } from '../logger'
import { getFFmpeg, runCommand } from './ffmpeg-commons'
import { presetCopy, presetVOD } from './ffmpeg-presets'
import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, hasAudioStream } from './ffprobe-utils'
const lTags = loggerTagsFactory('ffmpeg')
async function cutVideo (options: {
inputPath: string
outputPath: string
start?: number
end?: number
}) {
const { inputPath, outputPath } = options
logger.debug('Will cut the video.', { options, ...lTags() })
let command = getFFmpeg(inputPath, 'vod')
.output(outputPath)
command = presetCopy(command)
if (options.start) command.inputOption('-ss ' + options.start)
if (options.end) {
const endSeeking = options.end - (options.start || 0)
command.outputOption('-to ' + endSeeking)
}
await runCommand({ command })
}
async function addWatermark (options: {
inputPath: string
watermarkPath: string
outputPath: string
availableEncoders: AvailableEncoders
profile: string
}) {
const { watermarkPath, inputPath, outputPath, availableEncoders, profile } = options
logger.debug('Will add watermark to the video.', { options, ...lTags() })
const videoProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, videoProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, videoProbe)
let command = getFFmpeg(inputPath, 'vod')
.output(outputPath)
command.input(watermarkPath)
command = await presetVOD({
command,
input: inputPath,
availableEncoders,
profile,
resolution,
fps,
canCopyAudio: true,
canCopyVideo: false
})
const complexFilter: FilterSpecification[] = [
// Scale watermark
{
inputs: [ '[1]', '[0]' ],
filter: 'scale2ref',
options: {
w: 'oh*mdar',
h: `ih*${VIDEO_FILTERS.WATERMARK.SIZE_RATIO}`
},
outputs: [ '[watermark]', '[video]' ]
},
{
inputs: [ '[video]', '[watermark]' ],
filter: 'overlay',
options: {
x: `main_w - overlay_w - (main_h * ${VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO})`,
y: `main_h * ${VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO}`
}
}
]
command.complexFilter(complexFilter)
await runCommand({ command })
}
async function addIntroOutro (options: {
inputPath: string
introOutroPath: string
outputPath: string
type: 'intro' | 'outro'
availableEncoders: AvailableEncoders
profile: string
}) {
const { introOutroPath, inputPath, outputPath, availableEncoders, profile, type } = options
logger.debug('Will add intro/outro to the video.', { options, ...lTags() })
const mainProbe = await ffprobePromise(inputPath)
const fps = await getVideoStreamFPS(inputPath, mainProbe)
const { resolution } = await getVideoStreamDimensionsInfo(inputPath, mainProbe)
const mainHasAudio = await hasAudioStream(inputPath, mainProbe)
const introOutroProbe = await ffprobePromise(introOutroPath)
const introOutroHasAudio = await hasAudioStream(introOutroPath, introOutroProbe)
let command = getFFmpeg(inputPath, 'vod')
.output(outputPath)
command.input(introOutroPath)
if (!introOutroHasAudio && mainHasAudio) {
const duration = await getVideoStreamDuration(introOutroPath, introOutroProbe)
command.input('anullsrc')
command.withInputFormat('lavfi')
command.withInputOption('-t ' + duration)
}
command = await presetVOD({
command,
input: inputPath,
availableEncoders,
profile,
resolution,
fps,
canCopyAudio: false,
canCopyVideo: false
})
// Add black background to correctly scale intro/outro with padding
const complexFilter: FilterSpecification[] = [
{
inputs: [ '1', '0' ],
filter: 'scale2ref',
options: {
w: 'iw',
h: `ih`
},
outputs: [ 'intro-outro', 'main' ]
},
{
inputs: [ 'intro-outro', 'main' ],
filter: 'scale2ref',
options: {
w: 'iw',
h: `ih`
},
outputs: [ 'to-scale', 'main' ]
},
{
inputs: 'to-scale',
filter: 'drawbox',
options: {
t: 'fill'
},
outputs: [ 'to-scale-bg' ]
},
{
inputs: [ '1', 'to-scale-bg' ],
filter: 'scale2ref',
options: {
w: 'iw',
h: 'ih',
force_original_aspect_ratio: 'decrease',
flags: 'spline'
},
outputs: [ 'to-scale', 'to-scale-bg' ]
},
{
inputs: [ 'to-scale-bg', 'to-scale' ],
filter: 'overlay',
options: {
x: '(main_w - overlay_w)/2',
y: '(main_h - overlay_h)/2'
},
outputs: 'intro-outro-resized'
}
]
const concatFilter = {
inputs: [],
filter: 'concat',
options: {
n: 2,
v: 1,
unsafe: 1
},
outputs: [ 'v' ]
}
const introOutroFilterInputs = [ 'intro-outro-resized' ]
const mainFilterInputs = [ 'main' ]
if (mainHasAudio) {
mainFilterInputs.push('0:a')
if (introOutroHasAudio) {
introOutroFilterInputs.push('1:a')
} else {
// Silent input
introOutroFilterInputs.push('2:a')
}
}
if (type === 'intro') {
concatFilter.inputs = [ ...introOutroFilterInputs, ...mainFilterInputs ]
} else {
concatFilter.inputs = [ ...mainFilterInputs, ...introOutroFilterInputs ]
}
if (mainHasAudio) {
concatFilter.options['a'] = 1
concatFilter.outputs.push('a')
command.outputOption('-map [a]')
}
command.outputOption('-map [v]')
complexFilter.push(concatFilter)
command.complexFilter(complexFilter)
await runCommand({ command })
}
// ---------------------------------------------------------------------------
export {
cutVideo,
addIntroOutro,
addWatermark
}

View File

@ -0,0 +1,116 @@
import { getAvailableEncoders } from 'fluent-ffmpeg'
import { pick } from '@shared/core-utils'
import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptionsBuilderParams, EncoderProfile } from '@shared/models'
import { promisify0 } from '../core-utils'
import { logger, loggerTagsFactory } from '../logger'
const lTags = loggerTagsFactory('ffmpeg')
// Detect supported encoders by ffmpeg
let supportedEncoders: Map<string, boolean>
async function checkFFmpegEncoders (peertubeAvailableEncoders: AvailableEncoders): Promise<Map<string, boolean>> {
if (supportedEncoders !== undefined) {
return supportedEncoders
}
const getAvailableEncodersPromise = promisify0(getAvailableEncoders)
const availableFFmpegEncoders = await getAvailableEncodersPromise()
const searchEncoders = new Set<string>()
for (const type of [ 'live', 'vod' ]) {
for (const streamType of [ 'audio', 'video' ]) {
for (const encoder of peertubeAvailableEncoders.encodersToTry[type][streamType]) {
searchEncoders.add(encoder)
}
}
}
supportedEncoders = new Map<string, boolean>()
for (const searchEncoder of searchEncoders) {
supportedEncoders.set(searchEncoder, availableFFmpegEncoders[searchEncoder] !== undefined)
}
logger.info('Built supported ffmpeg encoders.', { supportedEncoders, searchEncoders, ...lTags() })
return supportedEncoders
}
function resetSupportedEncoders () {
supportedEncoders = undefined
}
// Run encoder builder depending on available encoders
// Try encoders by priority: if the encoder is available, run the chosen profile or fallback to the default one
// If the default one does not exist, check the next encoder
async function getEncoderBuilderResult (options: EncoderOptionsBuilderParams & {
streamType: 'video' | 'audio'
input: string
availableEncoders: AvailableEncoders
profile: string
videoType: 'vod' | 'live'
}) {
const { availableEncoders, profile, streamType, videoType } = options
const encodersToTry = availableEncoders.encodersToTry[videoType][streamType]
const encoders = availableEncoders.available[videoType]
for (const encoder of encodersToTry) {
if (!(await checkFFmpegEncoders(availableEncoders)).get(encoder)) {
logger.debug('Encoder %s not available in ffmpeg, skipping.', encoder, lTags())
continue
}
if (!encoders[encoder]) {
logger.debug('Encoder %s not available in peertube encoders, skipping.', encoder, lTags())
continue
}
// An object containing available profiles for this encoder
const builderProfiles: EncoderProfile<EncoderOptionsBuilder> = encoders[encoder]
let builder = builderProfiles[profile]
if (!builder) {
logger.debug('Profile %s for encoder %s not available. Fallback to default.', profile, encoder, lTags())
builder = builderProfiles.default
if (!builder) {
logger.debug('Default profile for encoder %s not available. Try next available encoder.', encoder, lTags())
continue
}
}
const result = await builder(
pick(options, [
'input',
'canCopyAudio',
'canCopyVideo',
'resolution',
'inputBitrate',
'fps',
'inputRatio',
'streamNum'
])
)
return {
result,
// If we don't have output options, then copy the input stream
encoder: result.copy === true
? 'copy'
: encoder
}
}
return null
}
export {
checkFFmpegEncoders,
resetSupportedEncoders,
getEncoderBuilderResult
}

View File

@ -0,0 +1,46 @@
import ffmpeg from 'fluent-ffmpeg'
import { FFMPEG_NICE } from '@server/initializers/constants'
import { runCommand } from './ffmpeg-commons'
function convertWebPToJPG (path: string, destination: string): Promise<void> {
const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
.output(destination)
return runCommand({ command, silent: true })
}
function processGIF (
path: string,
destination: string,
newSize: { width: number, height: number }
): Promise<void> {
const command = ffmpeg(path, { niceness: FFMPEG_NICE.THUMBNAIL })
.fps(20)
.size(`${newSize.width}x${newSize.height}`)
.output(destination)
return runCommand({ command })
}
async function generateThumbnailFromVideo (fromPath: string, folder: string, imageName: string) {
const pendingImageName = 'pending-' + imageName
const options = {
filename: pendingImageName,
count: 1,
folder
}
return new Promise<string>((res, rej) => {
ffmpeg(fromPath, { niceness: FFMPEG_NICE.THUMBNAIL })
.on('error', rej)
.on('end', () => res(imageName))
.thumbnail(options)
})
}
export {
convertWebPToJPG,
processGIF,
generateThumbnailFromVideo
}

View File

@ -0,0 +1,161 @@
import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
import { join } from 'path'
import { VIDEO_LIVE } from '@server/initializers/constants'
import { AvailableEncoders } from '@shared/models'
import { logger, loggerTagsFactory } from '../logger'
import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
import { getEncoderBuilderResult } from './ffmpeg-encoders'
import { addDefaultEncoderGlobalParams, addDefaultEncoderParams, applyEncoderOptions } from './ffmpeg-presets'
import { computeFPS } from './ffprobe-utils'
const lTags = loggerTagsFactory('ffmpeg')
async function getLiveTranscodingCommand (options: {
inputUrl: string
outPath: string
masterPlaylistName: string
resolutions: number[]
// Input information
fps: number
bitrate: number
ratio: number
availableEncoders: AvailableEncoders
profile: string
}) {
const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options
const command = getFFmpeg(inputUrl, 'live')
const varStreamMap: string[] = []
const complexFilter: FilterSpecification[] = [
{
inputs: '[v:0]',
filter: 'split',
options: resolutions.length,
outputs: resolutions.map(r => `vtemp${r}`)
}
]
command.outputOption('-sc_threshold 0')
addDefaultEncoderGlobalParams(command)
for (let i = 0; i < resolutions.length; i++) {
const resolution = resolutions[i]
const resolutionFPS = computeFPS(fps, resolution)
const baseEncoderBuilderParams = {
input: inputUrl,
availableEncoders,
profile,
canCopyAudio: true,
canCopyVideo: true,
inputBitrate: bitrate,
inputRatio: ratio,
resolution,
fps: resolutionFPS,
streamNum: i,
videoType: 'live' as 'live'
}
{
const streamType: StreamType = 'video'
const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error('No available live video encoder found')
}
command.outputOption(`-map [vout${resolution}]`)
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
logger.debug(
'Apply ffmpeg live video params from %s using %s profile.', builderResult.encoder, profile,
{ builderResult, fps: resolutionFPS, resolution, ...lTags() }
)
command.outputOption(`${buildStreamSuffix('-c:v', i)} ${builderResult.encoder}`)
applyEncoderOptions(command, builderResult.result)
complexFilter.push({
inputs: `vtemp${resolution}`,
filter: getScaleFilter(builderResult.result),
options: `w=-2:h=${resolution}`,
outputs: `vout${resolution}`
})
}
{
const streamType: StreamType = 'audio'
const builderResult = await getEncoderBuilderResult({ ...baseEncoderBuilderParams, streamType })
if (!builderResult) {
throw new Error('No available live audio encoder found')
}
command.outputOption('-map a:0')
addDefaultEncoderParams({ command, encoder: builderResult.encoder, fps: resolutionFPS, streamNum: i })
logger.debug(
'Apply ffmpeg live audio params from %s using %s profile.', builderResult.encoder, profile,
{ builderResult, fps: resolutionFPS, resolution, ...lTags() }
)
command.outputOption(`${buildStreamSuffix('-c:a', i)} ${builderResult.encoder}`)
applyEncoderOptions(command, builderResult.result)
}
varStreamMap.push(`v:${i},a:${i}`)
}
command.complexFilter(complexFilter)
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
command.outputOption('-var_stream_map', varStreamMap.join(' '))
return command
}
function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) {
const command = getFFmpeg(inputUrl, 'live')
command.outputOption('-c:v copy')
command.outputOption('-c:a copy')
command.outputOption('-map 0:a?')
command.outputOption('-map 0:v?')
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
return command
}
// ---------------------------------------------------------------------------
export {
getLiveTranscodingCommand,
getLiveMuxingCommand
}
// ---------------------------------------------------------------------------
function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
command.outputOption('-hls_flags delete_segments+independent_segments')
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
command.outputOption('-master_pl_name ' + masterPlaylistName)
command.outputOption(`-f hls`)
command.output(join(outPath, '%v.m3u8'))
}

View File

@ -0,0 +1,156 @@
import { FfmpegCommand } from 'fluent-ffmpeg'
import { pick } from 'lodash'
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { AvailableEncoders, EncoderOptions } from '@shared/models'
import { buildStreamSuffix, getScaleFilter, StreamType } from './ffmpeg-commons'
import { getEncoderBuilderResult } from './ffmpeg-encoders'
import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, hasAudioStream } from './ffprobe-utils'
const lTags = loggerTagsFactory('ffmpeg')
// ---------------------------------------------------------------------------
function addDefaultEncoderGlobalParams (command: FfmpegCommand) {
// avoid issues when transcoding some files: https://trac.ffmpeg.org/ticket/6375
command.outputOption('-max_muxing_queue_size 1024')
// strip all metadata
.outputOption('-map_metadata -1')
// allows import of source material with incompatible pixel formats (e.g. MJPEG video)
.outputOption('-pix_fmt yuv420p')
}
function addDefaultEncoderParams (options: {
command: FfmpegCommand
encoder: 'libx264' | string
fps: number
streamNum?: number
}) {
const { command, encoder, fps, streamNum } = options
if (encoder === 'libx264') {
// 3.1 is the minimal resource allocation for our highest supported resolution
command.outputOption(buildStreamSuffix('-level:v', streamNum) + ' 3.1')
if (fps) {
// Keyframe interval of 2 seconds for faster seeking and resolution switching.
// https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
// https://superuser.com/a/908325
command.outputOption(buildStreamSuffix('-g:v', streamNum) + ' ' + (fps * 2))
}
}
}
// ---------------------------------------------------------------------------
async function presetVOD (options: {
command: FfmpegCommand
input: string
availableEncoders: AvailableEncoders
profile: string
canCopyAudio: boolean
canCopyVideo: boolean
resolution: number
fps: number
scaleFilterValue?: string
}) {
const { command, input, profile, resolution, fps, scaleFilterValue } = options
let localCommand = command
.format('mp4')
.outputOption('-movflags faststart')
addDefaultEncoderGlobalParams(command)
const probe = await ffprobePromise(input)
// Audio encoder
const bitrate = await getVideoStreamBitrate(input, probe)
const videoStreamDimensions = await getVideoStreamDimensionsInfo(input, probe)
let streamsToProcess: StreamType[] = [ 'audio', 'video' ]
if (!await hasAudioStream(input, probe)) {
localCommand = localCommand.noAudio()
streamsToProcess = [ 'video' ]
}
for (const streamType of streamsToProcess) {
const builderResult = await getEncoderBuilderResult({
...pick(options, [ 'availableEncoders', 'canCopyAudio', 'canCopyVideo' ]),
input,
inputBitrate: bitrate,
inputRatio: videoStreamDimensions?.ratio || 0,
profile,
resolution,
fps,
streamType,
videoType: 'vod' as 'vod'
})
if (!builderResult) {
throw new Error('No available encoder found for stream ' + streamType)
}
logger.debug(
'Apply ffmpeg params from %s for %s stream of input %s using %s profile.',
builderResult.encoder, streamType, input, profile,
{ builderResult, resolution, fps, ...lTags() }
)
if (streamType === 'video') {
localCommand.videoCodec(builderResult.encoder)
if (scaleFilterValue) {
localCommand.outputOption(`-vf ${getScaleFilter(builderResult.result)}=${scaleFilterValue}`)
}
} else if (streamType === 'audio') {
localCommand.audioCodec(builderResult.encoder)
}
applyEncoderOptions(localCommand, builderResult.result)
addDefaultEncoderParams({ command: localCommand, encoder: builderResult.encoder, fps })
}
return localCommand
}
function presetCopy (command: FfmpegCommand): FfmpegCommand {
return command
.format('mp4')
.videoCodec('copy')
.audioCodec('copy')
}
function presetOnlyAudio (command: FfmpegCommand): FfmpegCommand {
return command
.format('mp4')
.audioCodec('copy')
.noVideo()
}
function applyEncoderOptions (command: FfmpegCommand, options: EncoderOptions): FfmpegCommand {
return command
.inputOptions(options.inputOptions ?? [])
.outputOptions(options.outputOptions ?? [])
}
// ---------------------------------------------------------------------------
export {
presetVOD,
presetCopy,
presetOnlyAudio,
addDefaultEncoderGlobalParams,
addDefaultEncoderParams,
applyEncoderOptions
}

View File

@ -0,0 +1,254 @@
import { Job } from 'bull'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { readFile, writeFile } from 'fs-extra'
import { dirname } from 'path'
import { pick } from '@shared/core-utils'
import { AvailableEncoders, VideoResolution } from '@shared/models'
import { logger, loggerTagsFactory } from '../logger'
import { getFFmpeg, runCommand } from './ffmpeg-commons'
import { presetCopy, presetOnlyAudio, presetVOD } from './ffmpeg-presets'
import { computeFPS, getVideoStreamFPS } from './ffprobe-utils'
import { VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
const lTags = loggerTagsFactory('ffmpeg')
// ---------------------------------------------------------------------------
type TranscodeVODOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
interface BaseTranscodeVODOptions {
type: TranscodeVODOptionsType
inputPath: string
outputPath: string
availableEncoders: AvailableEncoders
profile: string
resolution: number
isPortraitMode?: boolean
job?: Job
}
interface HLSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls'
copyCodecs: boolean
hlsPlaylist: {
videoFilename: string
}
}
interface HLSFromTSTranscodeOptions extends BaseTranscodeVODOptions {
type: 'hls-from-ts'
isAAC: boolean
hlsPlaylist: {
videoFilename: string
}
}
interface QuickTranscodeOptions extends BaseTranscodeVODOptions {
type: 'quick-transcode'
}
interface VideoTranscodeOptions extends BaseTranscodeVODOptions {
type: 'video'
}
interface MergeAudioTranscodeOptions extends BaseTranscodeVODOptions {
type: 'merge-audio'
audioPath: string
}
interface OnlyAudioTranscodeOptions extends BaseTranscodeVODOptions {
type: 'only-audio'
}
type TranscodeVODOptions =
HLSTranscodeOptions
| HLSFromTSTranscodeOptions
| VideoTranscodeOptions
| MergeAudioTranscodeOptions
| OnlyAudioTranscodeOptions
| QuickTranscodeOptions
// ---------------------------------------------------------------------------
const builders: {
[ type in TranscodeVODOptionsType ]: (c: FfmpegCommand, o?: TranscodeVODOptions) => Promise<FfmpegCommand> | FfmpegCommand
} = {
'quick-transcode': buildQuickTranscodeCommand,
'hls': buildHLSVODCommand,
'hls-from-ts': buildHLSVODFromTSCommand,
'merge-audio': buildAudioMergeCommand,
'only-audio': buildOnlyAudioCommand,
'video': buildVODCommand
}
async function transcodeVOD (options: TranscodeVODOptions) {
logger.debug('Will run transcode.', { options, ...lTags() })
let command = getFFmpeg(options.inputPath, 'vod')
.output(options.outputPath)
command = await builders[options.type](command, options)
await runCommand({ command, job: options.job })
await fixHLSPlaylistIfNeeded(options)
}
// ---------------------------------------------------------------------------
export {
transcodeVOD,
buildVODCommand,
TranscodeVODOptions,
TranscodeVODOptionsType
}
// ---------------------------------------------------------------------------
async function buildVODCommand (command: FfmpegCommand, options: TranscodeVODOptions) {
let fps = await getVideoStreamFPS(options.inputPath)
fps = computeFPS(fps, options.resolution)
let scaleFilterValue: string
if (options.resolution !== undefined) {
scaleFilterValue = options.isPortraitMode === true
? `w=${options.resolution}:h=-2`
: `w=-2:h=${options.resolution}`
}
command = await presetVOD({
...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
command,
input: options.inputPath,
canCopyAudio: true,
canCopyVideo: true,
fps,
scaleFilterValue
})
return command
}
function buildQuickTranscodeCommand (command: FfmpegCommand) {
command = presetCopy(command)
command = command.outputOption('-map_metadata -1') // strip all metadata
.outputOption('-movflags faststart')
return command
}
// ---------------------------------------------------------------------------
// Audio transcoding
// ---------------------------------------------------------------------------
async function buildAudioMergeCommand (command: FfmpegCommand, options: MergeAudioTranscodeOptions) {
command = command.loop(undefined)
const scaleFilterValue = getMergeAudioScaleFilterValue()
command = await presetVOD({
...pick(options, [ 'resolution', 'availableEncoders', 'profile' ]),
command,
input: options.audioPath,
canCopyAudio: true,
canCopyVideo: true,
fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
scaleFilterValue
})
command.outputOption('-preset:v veryfast')
command = command.input(options.audioPath)
.outputOption('-tune stillimage')
.outputOption('-shortest')
return command
}
function buildOnlyAudioCommand (command: FfmpegCommand, _options: OnlyAudioTranscodeOptions) {
command = presetOnlyAudio(command)
return command
}
// ---------------------------------------------------------------------------
// HLS transcoding
// ---------------------------------------------------------------------------
async function buildHLSVODCommand (command: FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options)
if (options.copyCodecs) command = presetCopy(command)
else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
else command = await buildVODCommand(command, options)
addCommonHLSVODCommandOptions(command, videoPath)
return command
}
function buildHLSVODFromTSCommand (command: FfmpegCommand, options: HLSFromTSTranscodeOptions) {
const videoPath = getHLSVideoPath(options)
command.outputOption('-c copy')
if (options.isAAC) {
// Required for example when copying an AAC stream from an MPEG-TS
// Since it's a bitstream filter, we don't need to reencode the audio
command.outputOption('-bsf:a aac_adtstoasc')
}
addCommonHLSVODCommandOptions(command, videoPath)
return command
}
function addCommonHLSVODCommandOptions (command: FfmpegCommand, outputPath: string) {
return command.outputOption('-hls_time 4')
.outputOption('-hls_list_size 0')
.outputOption('-hls_playlist_type vod')
.outputOption('-hls_segment_filename ' + outputPath)
.outputOption('-hls_segment_type fmp4')
.outputOption('-f hls')
.outputOption('-hls_flags single_file')
}
async function fixHLSPlaylistIfNeeded (options: TranscodeVODOptions) {
if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
const fileContent = await readFile(options.outputPath)
const videoFileName = options.hlsPlaylist.videoFilename
const videoFilePath = getHLSVideoPath(options)
// Fix wrong mapping with some ffmpeg versions
const newContent = fileContent.toString()
.replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
await writeFile(options.outputPath, newContent)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
}
// Avoid "height not divisible by 2" error
function getMergeAudioScaleFilterValue () {
return 'trunc(iw/2)*2:trunc(ih/2)*2'
}

View File

@ -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

View File

@ -0,0 +1,8 @@
export * from './ffmpeg-commons'
export * from './ffmpeg-edition'
export * from './ffmpeg-encoders'
export * from './ffmpeg-images'
export * from './ffmpeg-live'
export * from './ffmpeg-presets'
export * from './ffmpeg-vod'
export * from './ffprobe-utils'

View File

@ -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
}

View File

@ -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
}

View File

@ -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')
}
}

View File

@ -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',

View File

@ -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') },

View File

@ -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,

View File

@ -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'

View File

@ -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

View File

@ -0,0 +1,229 @@
import { Job } from 'bull'
import { move, remove } from 'fs-extra'
import { join } from 'path'
import { addIntroOutro, addWatermark, cutVideo } from '@server/helpers/ffmpeg'
import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
import { CONFIG } from '@server/initializers/config'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
import { generateWebTorrentVideoFilename } from '@server/lib/paths'
import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
import { isAbleToUploadVideo } from '@server/lib/user'
import { addMoveToObjectStorageJob, addOptimizeOrMergeAudioJob } from '@server/lib/video'
import { approximateIntroOutroAdditionalSize } from '@server/lib/video-editor'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { buildNextVideoState } from '@server/lib/video-state'
import { UserModel } from '@server/models/user/user'
import { VideoModel } from '@server/models/video/video'
import { VideoFileModel } from '@server/models/video/video-file'
import { MVideo, MVideoFile, MVideoFullLight, MVideoId, MVideoWithAllFiles } from '@server/types/models'
import { getLowercaseExtension, pick } from '@shared/core-utils'
import {
buildFileMetadata,
buildUUID,
ffprobePromise,
getFileSize,
getVideoStreamDimensionsInfo,
getVideoStreamDuration,
getVideoStreamFPS
} from '@shared/extra-utils'
import {
VideoEditionPayload,
VideoEditionTaskPayload,
VideoEditorTask,
VideoEditorTaskCutPayload,
VideoEditorTaskIntroPayload,
VideoEditorTaskOutroPayload,
VideoEditorTaskWatermarkPayload,
VideoState
} from '@shared/models'
import { logger, loggerTagsFactory } from '../../../helpers/logger'
const lTagsBase = loggerTagsFactory('video-edition')
async function processVideoEdition (job: Job) {
const payload = job.data as VideoEditionPayload
logger.info('Process video edition of %s in job %d.', payload.videoUUID, job.id)
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoUUID)
// No video, maybe deleted?
if (!video) {
logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
return undefined
}
await checkUserQuotaOrThrow(video, payload)
const inputFile = video.getMaxQualityFile()
const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
let tmpInputFilePath: string
let outputPath: string
for (const task of payload.tasks) {
const outputFilename = buildUUID() + inputFile.extname
outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
await processTask({
inputPath: tmpInputFilePath ?? originalFilePath,
video,
outputPath,
task
})
if (tmpInputFilePath) await remove(tmpInputFilePath)
// For the next iteration
tmpInputFilePath = outputPath
}
return outputPath
})
logger.info('Video edition ended for video %s.', video.uuid)
const newFile = await buildNewFile(video, editionResultPath)
const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
await move(editionResultPath, outputPath)
await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
await removeAllFiles(video, newFile)
await newFile.save()
video.state = buildNextVideoState()
video.duration = await getVideoStreamDuration(outputPath)
await video.save()
await federateVideoIfNeeded(video, false, undefined)
if (video.state === VideoState.TO_TRANSCODE) {
const user = await UserModel.loadByVideoId(video.id)
await addOptimizeOrMergeAudioJob(video, newFile, user, false)
} else if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
await addMoveToObjectStorageJob(video, false)
}
}
// ---------------------------------------------------------------------------
export {
processVideoEdition
}
// ---------------------------------------------------------------------------
type TaskProcessorOptions <T extends VideoEditionTaskPayload = VideoEditionTaskPayload> = {
inputPath: string
outputPath: string
video: MVideo
task: T
}
const taskProcessors: { [id in VideoEditorTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
'add-intro': processAddIntroOutro,
'add-outro': processAddIntroOutro,
'cut': processCut,
'add-watermark': processAddWatermark
}
async function processTask (options: TaskProcessorOptions) {
const { video, task } = options
logger.info('Processing %s task for video %s.', task.name, video.uuid, { task })
const processor = taskProcessors[options.task.name]
if (!process) throw new Error('Unknown task ' + task.name)
return processor(options)
}
function processAddIntroOutro (options: TaskProcessorOptions<VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload>) {
const { task } = options
return addIntroOutro({
...pick(options, [ 'inputPath', 'outputPath' ]),
introOutroPath: task.options.file,
type: task.name === 'add-intro'
? 'intro'
: 'outro',
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE
})
}
function processCut (options: TaskProcessorOptions<VideoEditorTaskCutPayload>) {
const { task } = options
return cutVideo({
...pick(options, [ 'inputPath', 'outputPath' ]),
start: task.options.start,
end: task.options.end
})
}
function processAddWatermark (options: TaskProcessorOptions<VideoEditorTaskWatermarkPayload>) {
const { task } = options
return addWatermark({
...pick(options, [ 'inputPath', 'outputPath' ]),
watermarkPath: task.options.file,
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.TRANSCODING.PROFILE
})
}
async function buildNewFile (video: MVideoId, path: string) {
const videoFile = new VideoFileModel({
extname: getLowercaseExtension(path),
size: await getFileSize(path),
metadata: await buildFileMetadata(path),
videoStreamingPlaylistId: null,
videoId: video.id
})
const probe = await ffprobePromise(path)
videoFile.fps = await getVideoStreamFPS(path, probe)
videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
return videoFile
}
async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) {
const hls = video.getHLSPlaylist()
if (hls) {
await video.removeStreamingPlaylistFiles(hls)
await hls.destroy()
}
for (const file of video.VideoFiles) {
if (file.id === webTorrentFileException.id) continue
await video.removeWebTorrentFileAndTorrent(file)
await file.destroy()
}
}
async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoEditionPayload) {
const user = await UserModel.loadByVideoId(video.id)
const filePathFinder = (i: number) => (payload.tasks[i] as VideoEditorTaskIntroPayload | VideoEditorTaskOutroPayload).options.file
const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
throw new Error('Quota exceeded for this user to edit the video')
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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(

View File

@ -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'

View File

@ -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'

View File

@ -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 {

View File

@ -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: {

View File

@ -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'

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,32 @@
import { MVideoFullLight } from "@server/types/models"
import { getVideoStreamDuration } from "@shared/extra-utils"
import { VideoEditorTask } from "@shared/models"
function buildTaskFileFieldname (indice: number, fieldName = 'file') {
return `tasks[${indice}][options][${fieldName}]`
}
function getTaskFile (files: Express.Multer.File[], indice: number, fieldName = 'file') {
return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
}
async function approximateIntroOutroAdditionalSize (video: MVideoFullLight, tasks: VideoEditorTask[], fileFinder: (i: number) => string) {
let additionalDuration = 0
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]
if (task.name !== 'add-intro' && task.name !== 'add-outro') continue
const filePath = fileFinder(i)
additionalDuration += await getVideoStreamDuration(filePath)
}
return (video.getMaxQualityFile().size / video.duration) * additionalDuration
}
export {
approximateIntroOutroAdditionalSize,
buildTaskFileFieldname,
getTaskFile
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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,

View File

@ -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
}

View File

@ -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'

View File

@ -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()
}

View File

@ -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()
}

View File

@ -0,0 +1,112 @@
import express from 'express'
import { body, param } from 'express-validator'
import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc'
import {
isEditorCutTaskValid,
isEditorTaskAddIntroOutroValid,
isEditorTaskAddWatermarkValid,
isValidEditorTasksArray
} from '@server/helpers/custom-validators/video-editor'
import { cleanUpReqFiles } from '@server/helpers/express-utils'
import { CONFIG } from '@server/initializers/config'
import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-editor'
import { isAudioFile } from '@shared/extra-utils'
import { HttpStatusCode, UserRight, VideoEditorCreateEdition, VideoEditorTask, VideoState } from '@shared/models'
import { logger } from '../../../helpers/logger'
import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared'
const videosEditorAddEditionValidator = [
param('videoId').custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
body('tasks').custom(isValidEditorTasksArray).withMessage('Should have a valid array of tasks'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videosEditorAddEditionValidator parameters.', { parameters: req.params, body: req.body, files: req.files })
if (CONFIG.VIDEO_EDITOR.ENABLED !== true) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Video editor is disabled on this instance'
})
return cleanUpReqFiles(req)
}
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
const body: VideoEditorCreateEdition = req.body
const files = req.files as Express.Multer.File[]
for (let i = 0; i < body.tasks.length; i++) {
const task = body.tasks[i]
if (!checkTask(req, task, i)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid`
})
return cleanUpReqFiles(req)
}
if (task.name === 'add-intro' || task.name === 'add-outro') {
const filePath = getTaskFile(files, i).path
// Our concat filter needs a video stream
if (await isAudioFile(filePath)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid: file does not contain a video stream`
})
return cleanUpReqFiles(req)
}
}
}
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
const video = res.locals.videoAll
if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Cannot edit video that is already waiting for transcoding/edition'
})
return cleanUpReqFiles(req)
}
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
// Try to make an approximation of bytes added by the intro/outro
const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFile(files, i).path)
if (await checkUserQuota(user, additionalBytes, res) === false) return cleanUpReqFiles(req)
return next()
}
]
// ---------------------------------------------------------------------------
export {
videosEditorAddEditionValidator
}
// ---------------------------------------------------------------------------
const taskCheckers: {
[id in VideoEditorTask['name']]: (task: VideoEditorTask, indice?: number, files?: Express.Multer.File[]) => boolean
} = {
'cut': isEditorCutTaskValid,
'add-intro': isEditorTaskAddIntroOutroValid,
'add-outro': isEditorTaskAddIntroOutroValid,
'add-watermark': isEditorTaskAddWatermarkValid
}
function checkTask (req: express.Request, task: VideoEditorTask, indice?: number) {
const checker = taskCheckers[task.name]
if (!checker) return false
return checker(task, indice, req.files as Express.Multer.File[])
}

View 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
}

View File

@ -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(', ')

View File

@ -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`)

View File

@ -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)
}
})
}

View File

@ -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

View File

@ -145,6 +145,9 @@ describe('Test config API validators', function () {
}
}
},
videoEditor: {
enabled: true
},
import: {
videos: {
concurrency: 1,

View File

@ -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'

View File

@ -0,0 +1,385 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import { HttpStatusCode, VideoEditorTask } from '@shared/models'
import {
cleanupTests,
createSingleServer,
PeerTubeServer,
setAccessTokensToServers,
VideoEditorCommand,
waitJobs
} from '@shared/server-commands'
describe('Test video editor API validator', function () {
let server: PeerTubeServer
let command: VideoEditorCommand
let userAccessToken: string
let videoUUID: string
// ---------------------------------------------------------------
before(async function () {
this.timeout(120_000)
server = await createSingleServer(1)
await setAccessTokensToServers([ server ])
userAccessToken = await server.users.generateUserAndToken('user1')
await server.config.enableMinimumTranscoding()
const { uuid } = await server.videos.quickUpload({ name: 'video' })
videoUUID = uuid
command = server.videoEditor
await waitJobs([ server ])
})
describe('Task creation', function () {
describe('Config settings', function () {
it('Should fail if editor is disabled', async function () {
await server.config.updateExistingSubConfig({
newConfig: {
videoEditor: {
enabled: false
}
}
})
await command.createEditionTasks({
videoId: videoUUID,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail to enable editor if transcoding is disabled', async function () {
await server.config.updateExistingSubConfig({
newConfig: {
videoEditor: {
enabled: true
},
transcoding: {
enabled: false
}
},
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed to enable video editor', async function () {
await server.config.updateExistingSubConfig({
newConfig: {
videoEditor: {
enabled: true
},
transcoding: {
enabled: true
}
}
})
})
})
describe('Common tasks', function () {
it('Should fail without token', async function () {
await command.createEditionTasks({
token: null,
videoId: videoUUID,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with another user token', async function () {
await command.createEditionTasks({
token: userAccessToken,
videoId: videoUUID,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail with an invalid video', async function () {
await command.createEditionTasks({
videoId: 'tintin',
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with an unknown video', async function () {
await command.createEditionTasks({
videoId: 42,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail with an already in transcoding state video', async function () {
await server.jobs.pauseJobQueue()
const { uuid } = await server.videos.quickUpload({ name: 'transcoded video' })
await command.createEditionTasks({
videoId: uuid,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.CONFLICT_409
})
await server.jobs.resumeJobQueue()
})
it('Should fail with a bad complex task', async function () {
await command.createEditionTasks({
videoId: videoUUID,
tasks: [
{
name: 'cut',
options: {
start: 1,
end: 2
}
},
{
name: 'hadock',
options: {
start: 1,
end: 2
}
}
] as any,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail without task', async function () {
await command.createEditionTasks({
videoId: videoUUID,
tasks: [],
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with too many tasks', async function () {
const tasks: VideoEditorTask[] = []
for (let i = 0; i < 110; i++) {
tasks.push({
name: 'cut',
options: {
start: 1
}
})
}
await command.createEditionTasks({
videoId: videoUUID,
tasks,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should succeed with correct parameters', async function () {
await server.jobs.pauseJobQueue()
await command.createEditionTasks({
videoId: videoUUID,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
})
it('Should fail with a video that is already waiting for edition', async function () {
this.timeout(120000)
await command.createEditionTasks({
videoId: videoUUID,
tasks: VideoEditorCommand.getComplexTask(),
expectedStatus: HttpStatusCode.CONFLICT_409
})
await server.jobs.resumeJobQueue()
await waitJobs([ server ])
})
})
describe('Cut task', function () {
async function cut (start: number, end: number, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
await command.createEditionTasks({
videoId: videoUUID,
tasks: [
{
name: 'cut',
options: {
start,
end
}
}
],
expectedStatus
})
}
it('Should fail with bad start/end', async function () {
const invalid = [
'tintin',
-1,
undefined
]
for (const value of invalid) {
await cut(value as any, undefined)
await cut(undefined, value as any)
}
})
it('Should fail with the same start/end', async function () {
await cut(2, 2)
})
it('Should fail with inconsistents start/end', async function () {
await cut(2, 1)
})
it('Should fail without start and end', async function () {
await cut(undefined, undefined)
})
it('Should succeed with the correct params', async function () {
this.timeout(120000)
await cut(0, 2, HttpStatusCode.NO_CONTENT_204)
await waitJobs([ server ])
})
})
describe('Watermark task', function () {
async function addWatermark (file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
await command.createEditionTasks({
videoId: videoUUID,
tasks: [
{
name: 'add-watermark',
options: {
file
}
}
],
expectedStatus
})
}
it('Should fail without waterkmark', async function () {
await addWatermark(undefined)
})
it('Should fail with an invalid watermark', async function () {
await addWatermark('video_short.mp4')
})
it('Should succeed with the correct params', async function () {
this.timeout(120000)
await addWatermark('thumbnail.jpg', HttpStatusCode.NO_CONTENT_204)
await waitJobs([ server ])
})
})
describe('Intro/Outro task', function () {
async function addIntroOutro (type: 'add-intro' | 'add-outro', file: string, expectedStatus = HttpStatusCode.BAD_REQUEST_400) {
await command.createEditionTasks({
videoId: videoUUID,
tasks: [
{
name: type,
options: {
file
}
}
],
expectedStatus
})
}
it('Should fail without file', async function () {
await addIntroOutro('add-intro', undefined)
await addIntroOutro('add-outro', undefined)
})
it('Should fail with an invalid file', async function () {
await addIntroOutro('add-intro', 'thumbnail.jpg')
await addIntroOutro('add-outro', 'thumbnail.jpg')
})
it('Should fail with a file that does not contain video stream', async function () {
await addIntroOutro('add-intro', 'sample.ogg')
await addIntroOutro('add-outro', 'sample.ogg')
})
it('Should succeed with the correct params', async function () {
this.timeout(120000)
await addIntroOutro('add-intro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
await waitJobs([ server ])
await addIntroOutro('add-outro', 'video_very_short_240p.mp4', HttpStatusCode.NO_CONTENT_204)
await waitJobs([ server ])
})
it('Should check total quota when creating the task', async function () {
this.timeout(120000)
const user = await server.users.create({ username: 'user_quota_1' })
const token = await server.login.getAccessToken('user_quota_1')
const { uuid } = await server.videos.quickUpload({ token, name: 'video_quota_1', fixture: 'video_short.mp4' })
const addIntroOutroByUser = (type: 'add-intro' | 'add-outro', expectedStatus: HttpStatusCode) => {
return command.createEditionTasks({
token,
videoId: uuid,
tasks: [
{
name: type,
options: {
file: 'video_short.mp4'
}
}
],
expectedStatus
})
}
await waitJobs([ server ])
const { videoQuotaUsed } = await server.users.getMyQuotaUsed({ token })
await server.users.update({ userId: user.id, videoQuota: Math.round(videoQuotaUsed * 2.5) })
// Still valid
await addIntroOutroByUser('add-intro', HttpStatusCode.NO_CONTENT_204)
await waitJobs([ server ])
// Too much quota
await addIntroOutroByUser('add-intro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
await addIntroOutroByUser('add-outro', HttpStatusCode.PAYLOAD_TOO_LARGE_413)
})
})
})
after(async function () {
await cleanupTests([ server ])
})
})

View File

@ -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