resolutions: {}
}
},
+ videoEditor: {
+ enabled: null
+ },
autoBlacklist: {
videos: {
ofUsers: {
</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>
}
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')
webtorrentControl.enable()
}
})
+
+ transcodingControl.valueChanges
+ .subscribe(newValue => {
+ if (newValue === false) {
+ videoEditorControl.setValue(false)
+ }
+ })
}
}
@use '_variables' as *;
@use '_mixins' as *;
+
my-embed {
display: block;
max-width: 500px;
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({
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),
--- /dev/null
+export * from './video-editor-edit.component'
+export * from './video-editor-edit.resolver'
--- /dev/null
+<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>
--- /dev/null
+@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;
+}
--- /dev/null
+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
+ }
+
+}
--- /dev/null
+
+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 })
+ }
+}
--- /dev/null
+export * from './video-editor.module'
--- /dev/null
+export * from './video-editor.service'
--- /dev/null
+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)))
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+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 { }
playlist: false,
download: true,
update: true,
+ editor: true,
blacklist: true,
delete: true,
report: true,
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>
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
}
canActivateChild: [ MetaGuard ]
},
+ {
+ path: 'video-editor',
+ loadChildren: () => import('./+video-editor/video-editor.module').then(m => m.VideoEditorModule),
+ canActivateChild: [ MetaGuard ]
+ },
+
// Matches /@:actorName
{
matcher: (url): UrlMatchResult => {
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()
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 ]
<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>
@use '_variables' as *;
+@use '_mixins' as *;
p-inputmask {
::ng-deep input {
width: 80px;
font-size: 15px;
- border: 0;
&:focus-within,
&:focus {
opacity: 0.5;
}
}
+
+ &.border-disabled {
+ ::ng-deep input {
+ border: 0;
+ }
+ }
+
+ &:not(.border-disabled) {
+ ::ng-deep input {
+ @include peertube-input-text(80px);
+ }
+ }
}
@Input() maxTimestamp: number
@Input() timestamp: number
@Input() disabled = false
+ @Input() inputName: string
+ @Input() disableBorder = true
@Output() inputBlur = new EventEmitter()
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,
liveInfo?: boolean
removeFiles?: boolean
transcoding?: boolean
+ editor?: boolean
}
@Component({
mute: true,
liveInfo: false,
removeFiles: false,
- transcoding: false
+ transcoding: false,
+ editor: true
}
@Input() placement = 'left'
private videoBlocklistService: VideoBlockService,
private screenService: ScreenService,
private videoService: VideoService,
- private redundancyService: RedundancyService
+ private redundancyService: RedundancyService,
+ private serverService: ServerService
) { }
get user () {
return this.video.isUpdatableBy(this.user)
}
+ isVideoEditable () {
+ return this.serverService.getHTMLConfig().videoEditor.enabled &&
+ this.video.state?.id === VideoState.PUBLISHED &&
+ this.video.isUpdatableBy(this.user)
+ }
+
isVideoRemovable () {
return this.video.isRemovableBy(this.user)
}
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(),
return $localize`To import`
}
+ if (video.state.id === VideoState.TO_EDIT) {
+ return $localize`To edit`
+ }
+
return ''
}
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:
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:
transcoding:
enabled: false
+video_editor:
+ enabled: false
+
live:
rtmp:
port: 1936
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
transcoding:
enabled: false
+
+video_editor:
+ enabled: false
local_buffer_update_interval: '5 seconds'
ip_view_expiration: '1 second'
+
+video_editor:
+ enabled: true
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'
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>')
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)
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)
}
}
},
+ videoEditor: {
+ enabled: CONFIG.VIDEO_EDITOR.ENABLED
+ },
import: {
videos: {
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
--- /dev/null
+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
+ }
+ }
+}
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'
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter)
+videosRouter.use('/', editorRouter)
videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter)
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'
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
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'
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)
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)
+import { UploadFilesForCheck } from 'express'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { isFileValid } from './misc'
.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
+ })
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
-function isFileFieldValid (
- files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
- field: string,
- optional = false
-) {
- // Should have files
- if (!files) return optional
- if (isArray(files)) return optional
+function isFileValid (options: {
+ files: UploadFilesForCheck
- // Should have a file
- const fileArray = files[field]
- if (!fileArray || fileArray.length === 0) {
- return optional
- }
+ maxSize: number | null
+ mimeTypeRegex: string | null
- // The file should exist
- const file = fileArray[0]
- if (!file || !file.originalname) return false
- return file
-}
+ field?: string
-function isFileMimeTypeValid (
- files: UploadFilesForCheck,
- mimeTypeRegex: string,
- field: string,
- optional = false
-) {
- // Should have files
- if (!files) return optional
- if (isArray(files)) return optional
+ optional?: boolean // Default false
+}) {
+ const { files, mimeTypeRegex, field, maxSize, optional = false } = options
- // 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) {
+ const fileArray = isArray(files)
+ ? files
+ : files[field]
+
+ if (!fileArray || !isArray(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)
}
// ---------------------------------------------------------------------------
areUUIDsValid,
toArray,
toIntArray,
- isFileFieldValid,
- isFileMimeTypeValid,
- isFileValid
+ isFileValid,
+ checkMimetypeRegex
}
-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'
.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) {
--- /dev/null
+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
+}
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'
.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
+ })
}
// ---------------------------------------------------------------------------
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
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) &&
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
.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) {
isVideoDescriptionValid,
isVideoFileInfoHashValid,
isVideoNameValid,
- isVideoTagsValid,
+ areVideoTagsValid,
isVideoFPSResolutionValid,
isScheduleVideoUpdatePrivacyValid,
isVideoOriginallyPublishedAtValid,
isVideoPrivacyValid,
isVideoFileResolutionValid,
isVideoFileSizeValid,
- isVideoImage,
+ isVideoImageValid,
isVideoSupportValid,
isVideoFilterValid
}
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'
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)
}
})
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
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)
+}
+++ /dev/null
-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
-}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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'))
+}
--- /dev/null
+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
+}
--- /dev/null
+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'
+}
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'
/**
*
*
*/
-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
return 'mp4a.40.2' // Fallback
}
+// ---------------------------------------------------------------------------
+// Resolutions
+// ---------------------------------------------------------------------------
+
function computeLowerResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
const configResolutions = type === 'vod'
? CONFIG.TRANSCODING.RESOLUTIONS
return resolutionsEnabled
}
+// ---------------------------------------------------------------------------
+// Can quick transcode
+// ---------------------------------------------------------------------------
+
async function canDoQuickTranscode (path: string): Promise<boolean> {
if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
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
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]
// ---------------------------------------------------------------------------
export {
- getVideoStreamCodec,
- getAudioStreamCodec,
- getVideoStreamSize,
- getVideoFileResolution,
- getMetadataFromFile,
+ // Re export ffprobe utils
+ getVideoStreamDimensionsInfo,
+ buildFileMetadata,
getMaxAudioBitrate,
- getVideoStreamFromFile,
- getDurationFromVideoFile,
+ getVideoStream,
+ getVideoStreamDuration,
getAudioStream,
- computeFPS,
- getVideoFileFPS,
+ hasAudioStream,
+ getVideoStreamFPS,
ffprobePromise,
+ getVideoStreamBitrate,
+
+ getVideoStreamCodec,
+ getAudioStreamCodec,
+
+ computeFPS,
getClosestFramerateStandard,
+
computeLowerResolutionsToTranscode,
- getVideoFileBitrate,
+
canDoQuickTranscode,
canDoQuickVideoTranscode,
canDoQuickAudioTranscode
--- /dev/null
+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'
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
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
}
}
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 = {
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) {
export {
createTorrentPromise,
updateTorrentMetadata,
+
createTorrentAndSetInfoHash,
+ createTorrentAndSetInfoHashFromPath,
+
generateMagnetUri,
downloadWebTorrentVideo
}
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'
}
}
-// 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
logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
}
- // Email verification
+ 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)
+async function clientsExist () {
+ const totalClients = await OAuthClientModel.countTotal()
+
+ return totalClients !== 0
+}
+
+// We get db by param to not import it in this file (import orders)
+async function usersExist () {
+ const totalUsers = await UserModel.countTotal()
+
+ return totalUsers !== 0
+}
+
+// We get db by param to not import it in this file (import orders)
+async function applicationExist () {
+ const totalApplication = await ApplicationModel.countTotal()
+
+ return totalApplication !== 0
+}
+
+async function checkFFmpegVersion () {
+ const version = await getFFmpegVersion()
+ const { major, minor } = parseSemVersion(version)
+
+ if (major < 4 || (major === 4 && minor < 1)) {
+ logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
+ }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+ checkConfig,
+ clientsExist,
+ checkFFmpegVersion,
+ usersExist,
+ applicationExist,
+ checkActivityPubUrls
+}
+
+// ---------------------------------------------------------------------------
+
+function checkEmailConfig () {
if (!isEmailEnabled()) {
if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
- return 'Emailer is disabled but you require signup 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.')
}
}
+}
- // NSFW policy
+function checkNSFWPolicyConfig () {
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
- }
+
+ 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)
}
+}
- // Redundancies
+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) {
- return 'Videos redundancy should have ' + available.join(' or ') + ' strategy instead of ' + r.strategy
+ 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) {
- return 'Video redundancy minimum lifetime should be >= 10 hours for strategy ' + r.strategy
+ 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) {
- return 'Redundancy video entries should have unique strategies'
+ 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)) {
- return 'Min views in recently added strategy is not a number'
+ throw new Error('Min views in recently added strategy is not a number')
}
} else {
- return 'Videos redundancy should be an array (you must uncomment lines containing - too)'
+ throw new Error('Videos redundancy should be an array (you must uncomment lines containing - too)')
}
+}
- // Remote redundancies
+function checkRemoteRedundancyConfig () {
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'
+ 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')
if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
logger.warn('Redundancy directory should be different than the videos folder.')
}
+}
- // Transcoding
+function checkTranscodingConfig () {
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.'
+ throw new Error('You need to enable at least WebTorrent transcoding or HLS transcoding.')
}
if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
- return 'Transcoding concurrency should be > 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) {
- return 'Video import concurrency should be > 0'
+ throw new Error('Video import concurrency should be > 0')
}
}
+}
- // Broadcast message
+function checkBroadcastMessageConfig () {
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
+ throw new Error('Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel)
}
}
+}
- // Search index
+function checkSearchConfig () {
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.'
+ throw new Error('You cannot enable search index without enabling remote URI search for users.')
}
}
+}
- // Live
+function checkLiveConfig () {
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.'
+ 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) {
- return 'You must enable at least RTMP or RTMPS'
+ throw new Error('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'
+ throw new Error('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'
+ throw new Error('You must specify a cert file to enable RTMPS')
}
}
}
+}
- // Object storage
+function checkObjectStorageConfig () {
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.'
+ throw new Error('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.'
+ throw new Error('streaming_playlists_bucket should be set when object storage support is enabled.')
}
if (
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.'
+ 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.'
+ )
}
}
-
- return null
-}
-
-// We get db by param to not import it in this file (import orders)
-async function clientsExist () {
- const totalClients = await OAuthClientModel.countTotal()
-
- return totalClients !== 0
-}
-
-// We get db by param to not import it in this file (import orders)
-async function usersExist () {
- const totalUsers = await UserModel.countTotal()
-
- return totalUsers !== 0
}
-// We get db by param to not import it in this file (import orders)
-async function applicationExist () {
- const totalApplication = await ApplicationModel.countTotal()
-
- return totalApplication !== 0
-}
-
-async function checkFFmpegVersion () {
- const version = await getFFmpegVersion()
- const { major, minor } = parseSemVersion(version)
-
- if (major < 4 || (major === 4 && minor < 1)) {
- logger.warn('Your ffmpeg version (%s) is outdated. PeerTube supports ffmpeg >= 4.1. Please upgrade.', version)
+function checkVideoEditorConfig () {
+ if (CONFIG.VIDEO_EDITOR.ENABLED === true && CONFIG.TRANSCODING.ENABLED === false) {
+ throw new Error('Video editor cannot be enabled if transcoding is disabled')
}
}
-
-// ---------------------------------------------------------------------------
-
-export {
- checkConfig,
- clientsExist,
- checkFFmpegVersion,
- usersExist,
- applicationExist,
- checkActivityPubUrls
-}
'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',
}
}
},
+ VIDEO_EDITOR: {
+ get ENABLED () { return config.get<boolean>('video_editor.enabled') }
+ },
IMPORT: {
VIDEOS: {
get CONCURRENCY () { return config.get<number>('import.videos.concurrency') },
'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
'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 } = {
'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
},
COMMONS: {
URL: { min: 5, max: 2000 } // Length
+ },
+ VIDEO_EDITOR: {
+ TASKS: { min: 1, max: 10 }, // Number of tasks
+ CUT_TIME: { min: 0 } // Value
}
}
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)
[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 } = {
// ---------------------------------------------------------------------------
+const VIDEO_FILTERS = {
+ WATERMARK: {
+ SIZE_RATIO: 1 / 10,
+ HORIZONTAL_MARGIN_RATIO: 1 / 20,
+ VERTICAL_MARGIN_RATIO: 1 / 20
+ }
+}
+
+// ---------------------------------------------------------------------------
+
export {
WEBSERVER,
API_VERSION,
PLUGIN_GLOBAL_CSS_FILE_NAME,
PLUGIN_GLOBAL_CSS_PATH,
PRIVATE_RSA_KEY_SIZE,
+ VIDEO_FILTERS,
ROUTE_CACHE_LIFETIME,
SORTABLE_COLUMNS,
HLS_STREAMING_PLAYLIST_DIRECTORY,
-import * as Sequelize from 'sequelize'
+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'
-import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
-import { readdir, rename } from 'fs-extra'
function up (utils: {
transaction: Sequelize.Transaction
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'
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'
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
--- /dev/null
+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')
+ }
+}
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
// ---------------------------------------------------------------------------
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)
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'
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)
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'
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,
})
if (!durationDone) {
- videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
+ videoWithFiles.duration = await getVideoStreamDuration(outputPath)
await videoWithFiles.save()
durationDone = true
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'
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'
mergeAudioVideofile,
optimizeOriginalVideofile,
transcodeNewWebTorrentResolution
-} from '../../transcoding/video-transcoding'
+} from '../../transcoding/transcoding'
type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
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)
JobType,
MoveObjectStoragePayload,
RefreshPayload,
+ VideoEditionPayload,
VideoFileImportPayload,
VideoImportPayload,
VideoLiveEndingPayload,
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'
{ 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 = {
'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[] = [
'video-redundancy',
'actor-keys',
'video-live-ending',
- 'move-to-object-storage'
+ 'move-to-object-storage',
+ 'video-edition'
]
class JobQueue {
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'
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(
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'
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'
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 {
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'
/**
*
port: CONFIG.LIVE.RTMP.PORT
}
},
+ videoEditor: {
+ enabled: CONFIG.VIDEO_EDITOR.ENABLED
+ },
import: {
videos: {
http: {
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'
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'
/**
*
* * 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 })
}
}
-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: [ ] }
}
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
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'
getHlsResolutionPlaylistFilename
} from '../paths'
import { VideoPathManager } from '../video-path-manager'
-import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
+import { VideoTranscodingProfilesManager } from './default-transcoding-profiles'
/**
*
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,
}
// 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
job
}
- await transcode(transcodeOptions)
+ await transcodeVOD(transcodeOptions)
return onWebTorrentVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
})
}
try {
- await transcode(transcodeOptions)
+ await transcodeVOD(transcodeOptions)
await remove(audioInputPath)
await remove(tmpPreviewPath)
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)
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 })
job
}
- await transcode(transcodeOptions)
+ await transcodeVOD(transcodeOptions)
// Create or update the playlist
const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(video)
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)
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 }
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
--- /dev/null
+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
+}
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()) {
resolution: DEFAULT_AUDIO_RESOLUTION,
videoUUID: video.uuid,
createHLSIfNeeded: true,
- isNewVideo: true
+ isNewVideo
}
} else {
dataInput = {
type: 'optimize-to-webtorrent',
videoUUID: video.uuid,
- isNewVideo: true
+ isNewVideo
}
}
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'),
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()
}
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
+}
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,
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'
import {
MUser,
MUserAccountId,
+ MUserId,
MVideo,
MVideoAccountLight,
MVideoFormattableDetails,
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
// 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
}
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
}
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'
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'
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()
}
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()
}
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()
}
--- /dev/null
+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[])
+}
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,
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
}
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'
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(', ')
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'
exists,
isBooleanValid,
isDateValid,
- isFileFieldValid,
+ isFileValid,
isIdValid,
isUUIDValid,
toArray,
} 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'
areValidationErrors,
checkCanSeePrivateVideo,
checkUserCanManageVideo,
+ checkUserQuota,
doesVideoChannelOfAccountExist,
doesVideoExist,
doesVideoFileOfVideoExist,
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()
// 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
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(', ')
),
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`
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
}
}
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`)
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'
return peertubeTruncate(this.description, { length: maxLength })
}
- getMaxQualityFileInfo () {
+ probeMaxQualityFile () {
const file = this.getMaxQualityFile()
const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
return {
audioStream,
- ...await getVideoFileResolution(originalFilePath, probe)
+ ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
}
})
}
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
}
}
},
+ videoEditor: {
+ enabled: true
+ },
import: {
videos: {
concurrency: 1,
import './video-captions'
import './video-channels'
import './video-comments'
+import './video-editor'
import './video-imports'
import './video-playlists'
import './videos'
--- /dev/null
+/* 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 ])
+ })
+})
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 {
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])
const servers = await Promise.all([
createSingleServer(1),
- createSingleServer(2, { transcoding: { enabled: false } })
+ createSingleServer(2)
])
server = servers[0]
remoteServer = servers[1]
await setDefaultChannelAvatar(server)
await setDefaultAccountAvatar(server)
+ await servers[1].config.disableTranscoding()
+
{
await server.users.create({ username: 'user1' })
const channel = {
const servers = await Promise.all([
createSingleServer(1),
- createSingleServer(2, { transcoding: { enabled: false } })
+ createSingleServer(2)
])
server = servers[0]
remoteServer = servers[1]
await setDefaultChannelAvatar([ remoteServer, server ])
await setDefaultAccountAvatar([ remoteServer, server ])
+ await servers[1].config.disableTranscoding()
+
{
const videoId = (await server.videos.upload()).uuid
expect(data.live.transcoding.resolutions['1440p']).to.be.false
expect(data.live.transcoding.resolutions['2160p']).to.be.false
+ expect(data.videoEditor.enabled).to.be.false
+
expect(data.import.videos.concurrency).to.equal(2)
expect(data.import.videos.http.enabled).to.be.true
expect(data.import.videos.torrent.enabled).to.be.true
expect(data.live.transcoding.resolutions['1080p']).to.be.true
expect(data.live.transcoding.resolutions['2160p']).to.be.true
+ expect(data.videoEditor.enabled).to.be.true
+
expect(data.import.videos.concurrency).to.equal(4)
expect(data.import.videos.http.enabled).to.be.false
expect(data.import.videos.torrent.enabled).to.be.false
}
}
},
+ videoEditor: {
+ enabled: true
+ },
import: {
videos: {
concurrency: 4,
it('Should have the correct AP stats', async function () {
this.timeout(60000)
- await servers[0].config.updateCustomSubConfig({
- newConfig: {
- transcoding: {
- enabled: false
- }
- }
- })
+ await servers[0].config.disableTranscoding()
const first = await servers[1].stats.get()
import 'mocha'
import * as chai from 'chai'
-import { getAudioStream, getVideoStreamSize } from '@server/helpers/ffprobe-utils'
+import { getAudioStream, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg'
import {
cleanupTests,
createMultipleServers,
expect(audioStream['codec_name']).to.be.equal('aac')
expect(audioStream['bit_rate']).to.be.at.most(384 * 8000)
- const size = await getVideoStreamSize(path)
- expect(size.height).to.equal(0)
- expect(size.width).to.equal(0)
+ const size = await getVideoStreamDimensionsInfo(path)
+ expect(size).to.not.exist
}
})
import './video-comments'
import './video-create-transcoding'
import './video-description'
+import './video-editor'
import './video-files'
import './video-hls'
import './video-imports'
--- /dev/null
+import { expect } from 'chai'
+import { expectStartWith, getAllFiles } from '@server/tests/shared'
+import { areObjectStorageTestsDisabled } from '@shared/core-utils'
+import { VideoEditorTask } from '@shared/models'
+import {
+ cleanupTests,
+ createMultipleServers,
+ doubleFollow,
+ ObjectStorageCommand,
+ PeerTubeServer,
+ setAccessTokensToServers,
+ setDefaultVideoChannel,
+ VideoEditorCommand,
+ waitJobs
+} from '@shared/server-commands'
+
+describe('Test video editor', function () {
+ let servers: PeerTubeServer[] = []
+ let videoUUID: string
+
+ async function checkDuration (server: PeerTubeServer, duration: number) {
+ const video = await server.videos.get({ id: videoUUID })
+
+ expect(video.duration).to.be.approximately(duration, 1)
+
+ for (const file of video.files) {
+ const metadata = await server.videos.getFileMetadata({ url: file.metadataUrl })
+
+ for (const stream of metadata.streams) {
+ expect(Math.round(stream.duration)).to.be.approximately(duration, 1)
+ }
+ }
+ }
+
+ async function renewVideo (fixture = 'video_short.webm') {
+ const video = await servers[0].videos.quickUpload({ name: 'video', fixture })
+ videoUUID = video.uuid
+
+ await waitJobs(servers)
+ }
+
+ async function createTasks (tasks: VideoEditorTask[]) {
+ await servers[0].videoEditor.createEditionTasks({ videoId: videoUUID, tasks })
+ await waitJobs(servers)
+ }
+
+ before(async function () {
+ this.timeout(120_000)
+
+ servers = await createMultipleServers(2)
+
+ await setAccessTokensToServers(servers)
+ await setDefaultVideoChannel(servers)
+
+ await doubleFollow(servers[0], servers[1])
+
+ await servers[0].config.enableMinimumTranscoding()
+
+ await servers[0].config.updateExistingSubConfig({
+ newConfig: {
+ videoEditor: {
+ enabled: true
+ }
+ }
+ })
+ })
+
+ describe('Cutting', function () {
+
+ it('Should cut the beginning of the video', async function () {
+ this.timeout(120_000)
+
+ await renewVideo()
+ await waitJobs(servers)
+
+ const beforeTasks = new Date()
+
+ await createTasks([
+ {
+ name: 'cut',
+ options: {
+ start: 2
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 3)
+
+ const video = await server.videos.get({ id: videoUUID })
+ expect(new Date(video.publishedAt)).to.be.below(beforeTasks)
+ }
+ })
+
+ it('Should cut the end of the video', async function () {
+ this.timeout(120_000)
+ await renewVideo()
+
+ await createTasks([
+ {
+ name: 'cut',
+ options: {
+ end: 2
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 2)
+ }
+ })
+
+ it('Should cut start/end of the video', async function () {
+ this.timeout(120_000)
+ await renewVideo('video_short1.webm') // 10 seconds video duration
+
+ await createTasks([
+ {
+ name: 'cut',
+ options: {
+ start: 2,
+ end: 6
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 4)
+ }
+ })
+ })
+
+ describe('Intro/Outro', function () {
+
+ it('Should add an intro', async function () {
+ this.timeout(120_000)
+ await renewVideo()
+
+ await createTasks([
+ {
+ name: 'add-intro',
+ options: {
+ file: 'video_short.webm'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 10)
+ }
+ })
+
+ it('Should add an outro', async function () {
+ this.timeout(120_000)
+ await renewVideo()
+
+ await createTasks([
+ {
+ name: 'add-outro',
+ options: {
+ file: 'video_very_short_240p.mp4'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 7)
+ }
+ })
+
+ it('Should add an intro/outro', async function () {
+ this.timeout(120_000)
+ await renewVideo()
+
+ await createTasks([
+ {
+ name: 'add-intro',
+ options: {
+ file: 'video_very_short_240p.mp4'
+ }
+ },
+ {
+ name: 'add-outro',
+ options: {
+ // Different frame rate
+ file: 'video_short2.webm'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 12)
+ }
+ })
+
+ it('Should add an intro to a video without audio', async function () {
+ this.timeout(120_000)
+ await renewVideo('video_short_no_audio.mp4')
+
+ await createTasks([
+ {
+ name: 'add-intro',
+ options: {
+ file: 'video_very_short_240p.mp4'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 7)
+ }
+ })
+
+ it('Should add an outro without audio to a video with audio', async function () {
+ this.timeout(120_000)
+ await renewVideo()
+
+ await createTasks([
+ {
+ name: 'add-outro',
+ options: {
+ file: 'video_short_no_audio.mp4'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 10)
+ }
+ })
+
+ it('Should add an outro without audio to a video with audio', async function () {
+ this.timeout(120_000)
+ await renewVideo('video_short_no_audio.mp4')
+
+ await createTasks([
+ {
+ name: 'add-outro',
+ options: {
+ file: 'video_short_no_audio.mp4'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ await checkDuration(server, 10)
+ }
+ })
+ })
+
+ describe('Watermark', function () {
+
+ it('Should add a watermark to the video', async function () {
+ this.timeout(120_000)
+ await renewVideo()
+
+ const video = await servers[0].videos.get({ id: videoUUID })
+ const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+ await createTasks([
+ {
+ name: 'add-watermark',
+ options: {
+ file: 'thumbnail.png'
+ }
+ }
+ ])
+
+ for (const server of servers) {
+ const video = await server.videos.get({ id: videoUUID })
+ const fileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+ for (const oldUrl of oldFileUrls) {
+ expect(fileUrls).to.not.include(oldUrl)
+ }
+ }
+ })
+ })
+
+ describe('Complex tasks', function () {
+ it('Should run a complex task', async function () {
+ this.timeout(240_000)
+ await renewVideo()
+
+ await createTasks(VideoEditorCommand.getComplexTask())
+
+ for (const server of servers) {
+ await checkDuration(server, 9)
+ }
+ })
+ })
+
+ describe('HLS only video edition', function () {
+
+ before(async function () {
+ // Disable webtorrent
+ await servers[0].config.updateExistingSubConfig({
+ newConfig: {
+ transcoding: {
+ webtorrent: {
+ enabled: false
+ }
+ }
+ }
+ })
+ })
+
+ it('Should run a complex task on HLS only video', async function () {
+ this.timeout(240_000)
+ await renewVideo()
+
+ await createTasks(VideoEditorCommand.getComplexTask())
+
+ for (const server of servers) {
+ const video = await server.videos.get({ id: videoUUID })
+ expect(video.files).to.have.lengthOf(0)
+
+ await checkDuration(server, 9)
+ }
+ })
+ })
+
+ describe('Object storage video edition', function () {
+ if (areObjectStorageTestsDisabled()) return
+
+ before(async function () {
+ await ObjectStorageCommand.prepareDefaultBuckets()
+
+ await servers[0].kill()
+ await servers[0].run(ObjectStorageCommand.getDefaultConfig())
+
+ await servers[0].config.enableMinimumTranscoding()
+ })
+
+ it('Should run a complex task on a video in object storage', async function () {
+ this.timeout(240_000)
+ await renewVideo()
+
+ const video = await servers[0].videos.get({ id: videoUUID })
+ const oldFileUrls = getAllFiles(video).map(f => f.fileUrl)
+
+ await createTasks(VideoEditorCommand.getComplexTask())
+
+ for (const server of servers) {
+ const video = await server.videos.get({ id: videoUUID })
+ const files = getAllFiles(video)
+
+ for (const f of files) {
+ expect(oldFileUrls).to.not.include(f.fileUrl)
+ }
+
+ for (const webtorrentFile of video.files) {
+ expectStartWith(webtorrentFile.fileUrl, ObjectStorageCommand.getWebTorrentBaseUrl())
+ }
+
+ for (const hlsFile of video.streamingPlaylists[0].files) {
+ expectStartWith(hlsFile.fileUrl, ObjectStorageCommand.getPlaylistBaseUrl())
+ }
+
+ await checkDuration(server, 9)
+ }
+ })
+ })
+
+ after(async function () {
+ await cleanupTests(servers)
+ })
+})
before(async function () {
this.timeout(120000)
- 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()
+ }
+
// Server 1 and server 2 follow each other
await doubleFollow(servers[0], servers[1])
before(async function () {
this.timeout(120000)
- servers = await createMultipleServers(3, { transcoding: { enabled: false } })
+ servers = await createMultipleServers(3)
// Get the access tokens
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await setDefaultAccountAvatar(servers)
+ for (const server of servers) {
+ await server.config.disableTranscoding()
+ }
+
// Server 1 and server 2 follow each other
await doubleFollow(servers[0], servers[1])
// Server 1 and server 3 follow each other
import 'mocha'
import * as chai from 'chai'
import { omit } from 'lodash'
-import { canDoQuickTranscode } from '@server/helpers/ffprobe-utils'
-import { generateHighBitrateVideo, generateVideoWithFramerate } from '@server/tests/shared'
+import { canDoQuickTranscode } from '@server/helpers/ffmpeg'
+import { generateHighBitrateVideo, generateVideoWithFramerate, getAllFiles } from '@server/tests/shared'
import { buildAbsoluteFixturePath, getMaxBitrate, getMinLimitBitrate } from '@shared/core-utils'
-import { getAudioStream, getMetadataFromFile, getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils'
+import {
+ getAudioStream,
+ buildFileMetadata,
+ getVideoStreamBitrate,
+ getVideoStreamFPS,
+ getVideoStreamDimensionsInfo,
+ hasAudioStream
+} from '@shared/extra-utils'
import { HttpStatusCode, VideoState } from '@shared/models'
import {
cleanupTests,
const file = videoDetails.files.find(f => f.resolution.id === 240)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const probe = await getAudioStream(path)
- expect(probe).to.not.have.property('audioStream')
+ expect(await hasAudioStream(path)).to.be.false
}
})
for (const resolution of [ 144, 240, 360, 480 ]) {
const file = videoDetails.files.find(f => f.resolution.id === resolution)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const fps = await getVideoFileFPS(path)
+ const fps = await getVideoStreamFPS(path)
expect(fps).to.be.below(31)
}
const file = videoDetails.files.find(f => f.resolution.id === 720)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const fps = await getVideoFileFPS(path)
+ const fps = await getVideoStreamFPS(path)
expect(fps).to.be.above(58).and.below(62)
}
{
tempFixturePath = await generateVideoWithFramerate(59)
- const fps = await getVideoFileFPS(tempFixturePath)
+ const fps = await getVideoStreamFPS(tempFixturePath)
expect(fps).to.be.equal(59)
}
{
const file = video.files.find(f => f.resolution.id === 240)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const fps = await getVideoFileFPS(path)
+ const fps = await getVideoStreamFPS(path)
expect(fps).to.be.equal(25)
}
{
const file = video.files.find(f => f.resolution.id === 720)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const fps = await getVideoFileFPS(path)
+ const fps = await getVideoStreamFPS(path)
expect(fps).to.be.equal(59)
}
}
const file = video.files.find(f => f.resolution.id === resolution)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const bitrate = await getVideoFileBitrate(path)
- const fps = await getVideoFileFPS(path)
- const dataResolution = await getVideoFileResolution(path)
+ const bitrate = await getVideoStreamBitrate(path)
+ const fps = await getVideoStreamFPS(path)
+ const dataResolution = await getVideoStreamDimensionsInfo(path)
expect(resolution).to.equal(resolution)
const file = video.files.find(f => f.resolution.id === r)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const bitrate = await getVideoFileBitrate(path)
+ const bitrate = await getVideoStreamBitrate(path)
const inputBitrate = 60_000
const limit = getMinLimitBitrate({ fps: 10, ratio: 1, resolution: r })
const video = await servers[1].videos.get({ id: videoUUID })
const file = video.files.find(f => f.resolution.id === 240)
const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl)
- const metadata = await getMetadataFromFile(path)
+ const metadata = await buildFileMetadata(path)
// expected format properties
for (const p of [
for (const server of servers) {
const videoDetails = await server.videos.get({ id: videoUUID })
- const videoFiles = videoDetails.files
- .concat(videoDetails.streamingPlaylists[0].files)
+ const videoFiles = getAllFiles(videoDetails)
expect(videoFiles).to.have.lengthOf(10)
for (const file of videoFiles) {
setAccessTokensToServers,
waitJobs
} from '@shared/server-commands'
+import { getAllFiles } from '../shared'
describe('Test update host scripts', function () {
let server: PeerTubeServer
for (const video of data) {
const videoDetails = await server.videos.get({ id: video.id })
- const files = videoDetails.files.concat(videoDetails.streamingPlaylists[0].files)
+ const files = getAllFiles(videoDetails)
expect(files).to.have.lengthOf(8)
before(async function () {
this.timeout(60000)
- await servers[0].config.updateCustomSubConfig({
- newConfig: {
- transcoding: {
- enabled: false
- }
- }
- })
+ await servers[0].config.disableTranscoding()
for (const name of [ 'bad embed', 'good embed' ]) {
{
import 'mocha'
import { expect } from 'chai'
-import { getAudioStream, getVideoFileFPS, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils'
+import { getAudioStream, getVideoStreamFPS, getVideoStream } from '@server/helpers/ffmpeg'
+import { VideoPrivacy } from '@shared/models'
import {
cleanupTests,
createSingleServer,
testFfmpegStreamError,
waitJobs
} from '@shared/server-commands'
-import { VideoPrivacy } from '@shared/models'
async function createLiveWrapper (server: PeerTubeServer) {
const liveAttributes = {
async function checkLiveFPS (uuid: string, type: 'above' | 'below', fps: number) {
const playlistUrl = `${server.url}/static/streaming-playlists/hls/${uuid}/0.m3u8`
- const videoFPS = await getVideoFileFPS(playlistUrl)
+ const videoFPS = await getVideoStreamFPS(playlistUrl)
if (type === 'above') {
expect(videoFPS).to.be.above(fps)
const audioProbe = await getAudioStream(path)
expect(audioProbe.audioStream.codec_name).to.equal('opus')
- const videoProbe = await getVideoStreamFromFile(path)
+ const videoProbe = await getVideoStream(path)
expect(videoProbe.codec_name).to.equal('vp9')
})
const audioProbe = await getAudioStream(playlistUrl)
expect(audioProbe.audioStream.codec_name).to.equal('opus')
- const videoProbe = await getVideoStreamFromFile(playlistUrl)
+ const videoProbe = await getVideoStream(playlistUrl)
expect(videoProbe.codec_name).to.equal('h264')
})
})
import { ensureDir, pathExists } from 'fs-extra'
import { dirname } from 'path'
import { buildAbsoluteFixturePath, getMaxBitrate } from '@shared/core-utils'
-import { getVideoFileBitrate, getVideoFileFPS, getVideoFileResolution } from '@shared/extra-utils'
+import { getVideoStreamBitrate, getVideoStreamFPS, getVideoStreamDimensionsInfo } from '@shared/extra-utils'
async function ensureHasTooBigBitrate (fixturePath: string) {
- const bitrate = await getVideoFileBitrate(fixturePath)
- const dataResolution = await getVideoFileResolution(fixturePath)
- const fps = await getVideoFileFPS(fixturePath)
+ const bitrate = await getVideoStreamBitrate(fixturePath)
+ const dataResolution = await getVideoStreamDimensionsInfo(fixturePath)
+ const fps = await getVideoStreamFPS(fixturePath)
const maxBitrate = getMaxBitrate({ ...dataResolution, fps })
expect(bitrate).to.be.above(maxBitrate)
return res
}
+function getAllFiles (video: VideoDetails) {
+ const files = video.files
+
+ if (video.streamingPlaylists[0]) {
+ return files.concat(video.streamingPlaylists[0].files)
+ }
+
+ return files
+}
+
// ---------------------------------------------------------------------------
export {
checkUploadVideoParam,
uploadRandomVideoOnServers,
checkVideoFilesWereRemoved,
- saveVideoInServers
+ saveVideoInServers,
+ getAllFiles
}
MVideoRedundancyVideo,
MVideoShareActor,
MVideoThumbnail
-} from '../../types/models'
+} from './models'
import { Writable } from 'stream'
declare module 'express' {
export type UploadFileForCheck = {
originalname: string
mimetype: string
+ size: number
}
export type UploadFilesForCheck = {
})
}
+// ---------------------------------------------------------------------------
+// Audio
+// ---------------------------------------------------------------------------
+
async function isAudioFile (path: string, existingProbe?: FfprobeData) {
- const videoStream = await getVideoStreamFromFile(path, existingProbe)
+ const videoStream = await getVideoStream(path, existingProbe)
return !videoStream
}
+async function hasAudioStream (path: string, existingProbe?: FfprobeData) {
+ const { audioStream } = await getAudioStream(path, existingProbe)
+
+ return !!audioStream
+}
+
async function getAudioStream (videoPath: string, existingProbe?: FfprobeData) {
// without position, ffprobe considers the last input only
// we make it consider the first input only
}
}
-async function getVideoStreamSize (path: string, existingProbe?: FfprobeData): Promise<{ width: number, height: number }> {
- const videoStream = await getVideoStreamFromFile(path, existingProbe)
-
- return videoStream === null
- ? { width: 0, height: 0 }
- : { width: videoStream.width, height: videoStream.height }
-}
+// ---------------------------------------------------------------------------
+// Video
+// ---------------------------------------------------------------------------
-async function getVideoFileResolution (path: string, existingProbe?: FfprobeData) {
- const size = await getVideoStreamSize(path, existingProbe)
+async function getVideoStreamDimensionsInfo (path: string, existingProbe?: FfprobeData) {
+ const videoStream = await getVideoStream(path, existingProbe)
+ if (!videoStream) return undefined
return {
- width: size.width,
- height: size.height,
- ratio: Math.max(size.height, size.width) / Math.min(size.height, size.width),
- resolution: Math.min(size.height, size.width),
- isPortraitMode: size.height > size.width
+ width: videoStream.width,
+ height: videoStream.height,
+ ratio: Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width),
+ resolution: Math.min(videoStream.height, videoStream.width),
+ isPortraitMode: videoStream.height > videoStream.width
}
}
-async function getVideoFileFPS (path: string, existingProbe?: FfprobeData) {
- const videoStream = await getVideoStreamFromFile(path, existingProbe)
- if (videoStream === null) return 0
+async function getVideoStreamFPS (path: string, existingProbe?: FfprobeData) {
+ const videoStream = await getVideoStream(path, existingProbe)
+ if (!videoStream) return 0
for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
const valuesText: string = videoStream[key]
return 0
}
-async function getMetadataFromFile (path: string, existingProbe?: FfprobeData) {
+async function buildFileMetadata (path: string, existingProbe?: FfprobeData) {
const metadata = existingProbe || await ffprobePromise(path)
return new VideoFileMetadata(metadata)
}
-async function getVideoFileBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
- const metadata = await getMetadataFromFile(path, existingProbe)
+async function getVideoStreamBitrate (path: string, existingProbe?: FfprobeData): Promise<number> {
+ const metadata = await buildFileMetadata(path, existingProbe)
let bitrate = metadata.format.bit_rate as number
if (bitrate && !isNaN(bitrate)) return bitrate
- const videoStream = await getVideoStreamFromFile(path, existingProbe)
+ const videoStream = await getVideoStream(path, existingProbe)
if (!videoStream) return undefined
bitrate = videoStream?.bit_rate
return undefined
}
-async function getDurationFromVideoFile (path: string, existingProbe?: FfprobeData) {
- const metadata = await getMetadataFromFile(path, existingProbe)
+async function getVideoStreamDuration (path: string, existingProbe?: FfprobeData) {
+ const metadata = await buildFileMetadata(path, existingProbe)
return Math.round(metadata.format.duration)
}
-async function getVideoStreamFromFile (path: string, existingProbe?: FfprobeData) {
- const metadata = await getMetadataFromFile(path, existingProbe)
-
- return metadata.streams.find(s => s.codec_type === 'video') || null
-}
-
-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
+async function getVideoStream (path: string, existingProbe?: FfprobeData) {
+ const metadata = await buildFileMetadata(path, existingProbe)
- return true
+ return metadata.streams.find(s => s.codec_type === 'video')
}
// ---------------------------------------------------------------------------
export {
- getVideoStreamSize,
- getVideoFileResolution,
- getMetadataFromFile,
+ getVideoStreamDimensionsInfo,
+ buildFileMetadata,
getMaxAudioBitrate,
- getVideoStreamFromFile,
- getDurationFromVideoFile,
+ getVideoStream,
+ getVideoStreamDuration,
getAudioStream,
- getVideoFileFPS,
+ getVideoStreamFPS,
isAudioFile,
ffprobePromise,
- getVideoFileBitrate,
- canDoQuickAudioTranscode
+ getVideoStreamBitrate,
+ hasAudioStream
}
}
}
+ videoEditor: {
+ enabled: boolean
+ }
+
import: {
videos: {
concurrency: number
import { ContextType } from '../activitypub/context'
+import { VideoEditorTaskCut } from '../videos/editor'
import { VideoResolution } from '../videos/file/video-resolution.enum'
import { SendEmailOptions } from './emailer.model'
| 'video-live-ending'
| 'actor-keys'
| 'move-to-object-storage'
+ | 'video-edition'
export interface Job {
id: number
videoUUID: string
isNewVideo: boolean
}
+
+export type VideoEditorTaskCutPayload = VideoEditorTaskCut
+
+export type VideoEditorTaskIntroPayload = {
+ name: 'add-intro'
+
+ options: {
+ file: string
+ }
+}
+
+export type VideoEditorTaskOutroPayload = {
+ name: 'add-outro'
+
+ options: {
+ file: string
+ }
+}
+
+export type VideoEditorTaskWatermarkPayload = {
+ name: 'add-watermark'
+
+ options: {
+ file: string
+ }
+}
+
+export type VideoEditionTaskPayload =
+ VideoEditorTaskCutPayload |
+ VideoEditorTaskIntroPayload |
+ VideoEditorTaskOutroPayload |
+ VideoEditorTaskWatermarkPayload
+
+export interface VideoEditionPayload {
+ videoUUID: string
+ tasks: VideoEditionTaskPayload[]
+}
}
}
+ videoEditor: {
+ enabled: boolean
+ }
+
import: {
videos: {
http: {
--- /dev/null
+export * from './video-editor-create-edit.model'
--- /dev/null
+export interface VideoEditorCreateEdition {
+ tasks: VideoEditorTask[]
+}
+
+export type VideoEditorTask =
+ VideoEditorTaskCut |
+ VideoEditorTaskIntro |
+ VideoEditorTaskOutro |
+ VideoEditorTaskWatermark
+
+export interface VideoEditorTaskCut {
+ name: 'cut'
+
+ options: {
+ start?: number
+ end?: number
+ }
+}
+
+export interface VideoEditorTaskIntro {
+ name: 'add-intro'
+
+ options: {
+ file: Blob | string
+ }
+}
+
+export interface VideoEditorTaskOutro {
+ name: 'add-outro'
+
+ options: {
+ file: Blob | string
+ }
+}
+
+export interface VideoEditorTaskWatermark {
+ name: 'add-watermark'
+
+ options: {
+ file: Blob | string
+ }
+}
export * from './change-ownership'
export * from './channel'
export * from './comment'
+export * from './editor'
export * from './live'
export * from './file'
export * from './import'
MIN: number
STANDARD: number[]
HD_STANDARD: number[]
+ AUDIO_MERGE: number
AVERAGE: number
MAX: number
KEEP_ORIGIN_FPS_RESOLUTION_MIN: number
resolution: VideoResolution
- // Could be null for "merge audio" transcoding
- fps?: number
+ // If PeerTube applies a filter, transcoding profile must not copy input stream
+ canCopyAudio: boolean
+ canCopyVideo: boolean
+
+ fps: number
// Could be undefined if we could not get input bitrate (some RTMP streams for example)
inputBitrate: number
LIVE_ENDED = 5,
TO_MOVE_TO_EXTERNAL_STORAGE = 6,
TRANSCODING_FAILED = 7,
- TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8
+ TO_MOVE_TO_EXTERNAL_STORAGE_FAILED = 8,
+ TO_EDIT = 9
}
newConfig: {
transcoding: {
enabled: false
+ },
+ videoEditor: {
+ enabled: false
}
}
})
newConfig: {
transcoding: {
enabled: true,
+
+ allowAudioFiles: true,
+ allowAdditionalExtensions: true,
+
resolutions: ConfigCommand.getCustomConfigResolutions(true),
webtorrent: {
})
}
+ enableMinimumTranscoding (webtorrent = true, hls = true) {
+ return this.updateExistingSubConfig({
+ newConfig: {
+ transcoding: {
+ enabled: true,
+ resolutions: {
+ ...ConfigCommand.getCustomConfigResolutions(false),
+
+ '240p': true
+ },
+
+ webtorrent: {
+ enabled: webtorrent
+ },
+ hls: {
+ enabled: hls
+ }
+ }
+ }
+ })
+ }
+
getConfig (options: OverrideCommandOptions = {}) {
const path = '/api/v1/config'
async updateExistingSubConfig (options: OverrideCommandOptions & {
newConfig: DeepPartial<CustomConfig>
}) {
- const existing = await this.getCustomConfig(options)
+ const existing = await this.getCustomConfig({ ...options, expectedStatus: HttpStatusCode.OK_200 })
return this.updateCustomConfig({ ...options, newCustomConfig: merge({}, existing, options.newConfig) })
}
}
}
},
+ videoEditor: {
+ enabled: false
+ },
import: {
videos: {
concurrency: 3,
PlaylistsCommand,
ServicesCommand,
StreamingPlaylistsCommand,
+ VideoEditorCommand,
VideosCommand
} from '../videos'
import { CommentsCommand } from '../videos/comments-command'
login?: LoginCommand
users?: UsersCommand
objectStorage?: ObjectStorageCommand
+ videoEditor?: VideoEditorCommand
videos?: VideosCommand
constructor (options: { serverNumber: number } | { url: string }) {
this.users = new UsersCommand(this)
this.videos = new VideosCommand(this)
this.objectStorage = new ObjectStorageCommand(this)
+ this.videoEditor = new VideoEditorCommand(this)
}
}
export * from './services-command'
export * from './streaming-playlists-command'
export * from './comments-command'
+export * from './video-editor-command'
export * from './videos-command'
--- /dev/null
+import { HttpStatusCode, VideoEditorTask } from '@shared/models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class VideoEditorCommand extends AbstractCommand {
+
+ static getComplexTask (): VideoEditorTask[] {
+ return [
+ // Total duration: 2
+ {
+ name: 'cut',
+ options: {
+ start: 1,
+ end: 3
+ }
+ },
+
+ // Total duration: 7
+ {
+ name: 'add-outro',
+ options: {
+ file: 'video_short.webm'
+ }
+ },
+
+ {
+ name: 'add-watermark',
+ options: {
+ file: 'thumbnail.png'
+ }
+ },
+
+ // Total duration: 9
+ {
+ name: 'add-intro',
+ options: {
+ file: 'video_very_short_240p.mp4'
+ }
+ }
+ ]
+ }
+
+ createEditionTasks (options: OverrideCommandOptions & {
+ videoId: number | string
+ tasks: VideoEditorTask[]
+ }) {
+ const path = '/api/v1/videos/' + options.videoId + '/editor/edit'
+ const attaches: { [id: string]: any } = {}
+
+ for (let i = 0; i < options.tasks.length; i++) {
+ const task = options.tasks[i]
+
+ if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') {
+ attaches[`tasks[${i}][options][file]`] = task.options.file
+ }
+ }
+
+ return this.postUploadRequest({
+ ...options,
+
+ path,
+ attaches,
+ fields: { tasks: options.tasks },
+ implicitToken: true,
+ defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+ })
+ }
+}