Merge branch 'feature/audio-upload' into develop
This commit is contained in:
commit
73b3aa6429
|
@ -29,8 +29,8 @@ install:
|
|||
- CC=gcc-4.9 CXX=g++-4.9 yarn install
|
||||
|
||||
before_script:
|
||||
- wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.0.2-64bit-static.tar.xz"
|
||||
- tar xf ffmpeg-release-4.0.2-64bit-static.tar.xz
|
||||
- wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.0.3-64bit-static.tar.xz"
|
||||
- tar xf ffmpeg-release-4.0.3-64bit-static.tar.xz
|
||||
- mkdir -p $HOME/bin
|
||||
- cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin
|
||||
- export PATH=$HOME/bin:$PATH
|
||||
|
|
|
@ -286,6 +286,14 @@
|
|||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles"
|
||||
i18n-labelText labelText="Allow audio files upload"
|
||||
i18n-helpHtml helpHtml="Allow your users to upload audio files that will be merged with the preview file on upload"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="transcodingThreads">Transcoding threads</label>
|
||||
<div class="peertube-select-container">
|
||||
|
|
|
@ -116,6 +116,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
enabled: null,
|
||||
threads: this.customConfigValidatorsService.TRANSCODING_THREADS,
|
||||
allowAdditionalExtensions: null,
|
||||
allowAudioFiles: null,
|
||||
resolutions: {}
|
||||
},
|
||||
autoBlacklist: {
|
||||
|
|
|
@ -57,10 +57,12 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-image-upload
|
||||
i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
|
||||
previewWidth="200px" previewHeight="110px"
|
||||
></my-image-upload>
|
||||
<label i18n>Playlist thumbnail</label>
|
||||
|
||||
<my-preview-upload
|
||||
i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"
|
||||
previewWidth="223px" previewHeight="122px"
|
||||
></my-preview-upload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { FormReactive } from '@app/shared'
|
||||
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
|
||||
import { ServerService } from '@app/core'
|
||||
import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
|
||||
|
||||
export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
|
||||
|
||||
<my-button i18n-label label="Change ownership"
|
||||
className="action-button-change-ownership"
|
||||
className="action-button-change-ownership grey-button"
|
||||
icon="im-with-her"
|
||||
(click)="changeOwnership($event, video)"
|
||||
></my-button>
|
||||
|
|
|
@ -5,16 +5,9 @@
|
|||
@include peertube-button-link;
|
||||
@include button-with-icon(21px, 0, -2px);
|
||||
|
||||
font-weight: $font-semibold;
|
||||
color: $grey-foreground-color;
|
||||
background-color: $grey-background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-background-hover-color;
|
||||
}
|
||||
|
||||
my-global-icon {
|
||||
@include apply-svg-color($grey-foreground-color);
|
||||
// FIXME: Firefox does not apply global .orange-button icon color
|
||||
&.orange-button {
|
||||
@include apply-svg-color(#fff)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import { GlobalIconName } from '@app/shared/images/global-icon.component'
|
|||
|
||||
export class ButtonComponent {
|
||||
@Input() label = ''
|
||||
@Input() className: string = undefined
|
||||
@Input() className = 'grey-button'
|
||||
@Input() icon: GlobalIconName = undefined
|
||||
@Input() title: string = undefined
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<span class="action-button action-button-delete" [title]="getTitle()" role="button">
|
||||
<span class="action-button action-button-delete grey-button" [title]="getTitle()" role="button">
|
||||
<my-global-icon iconName="delete"></my-global-icon>
|
||||
|
||||
<span class="button-label" *ngIf="label">{{ label }}</span>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit">
|
||||
<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
|
||||
<my-global-icon iconName="edit"></my-global-icon>
|
||||
|
||||
<span class="button-label" *ngIf="label">{{ label }}</span>
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
<div class="root">
|
||||
<div class="button-file">
|
||||
<div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
|
||||
<my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
|
||||
|
||||
<span>{{ inputLabel }}</span>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
[name]="inputName" [id]="inputName" [accept]="extensions"
|
||||
|
@ -8,7 +11,5 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
|
||||
|
||||
<div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
|
||||
</div>
|
||||
|
|
|
@ -8,13 +8,11 @@
|
|||
|
||||
.button-file {
|
||||
@include peertube-button-file(auto);
|
||||
@include grey-button;
|
||||
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.file-constraints {
|
||||
margin-left: 5px;
|
||||
font-size: 13px;
|
||||
&.with-icon {
|
||||
@include button-with-icon;
|
||||
}
|
||||
}
|
||||
|
||||
.filename {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@ang
|
|||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { Notifier } from '@app/core'
|
||||
import { I18n } from '@ngx-translate/i18n-polyfill'
|
||||
import { GlobalIconName } from '@app/shared/images/global-icon.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-reactive-file',
|
||||
|
@ -21,6 +22,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
|
|||
@Input() extensions: string[] = []
|
||||
@Input() maxFileSize: number
|
||||
@Input() displayFilename = false
|
||||
@Input() icon: GlobalIconName
|
||||
|
||||
@Output() fileChanged = new EventEmitter<Blob>()
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<div class="root">
|
||||
<my-reactive-file
|
||||
[inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
|
||||
(fileChanged)="onFileChanged($event)"
|
||||
></my-reactive-file>
|
||||
|
||||
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
|
||||
<div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
|
||||
</div>
|
|
@ -1,18 +0,0 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.root {
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.preview {
|
||||
border: 2px solid grey;
|
||||
border-radius: 4px;
|
||||
margin-left: 50px;
|
||||
|
||||
&.no-image {
|
||||
background-color: #ececec;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<div class="root">
|
||||
<div class="preview-container">
|
||||
<my-reactive-file
|
||||
[inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
|
||||
icon="edit" (fileChanged)="onFileChanged($event)"
|
||||
></my-reactive-file>
|
||||
|
||||
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
|
||||
<div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
|
||||
</div>
|
||||
|
||||
<div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})</div>
|
||||
</div>
|
|
@ -0,0 +1,27 @@
|
|||
@import '_variables';
|
||||
@import '_mixins';
|
||||
|
||||
.root {
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
|
||||
my-reactive-file {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
border: 2px solid grey;
|
||||
border-radius: 4px;
|
||||
|
||||
&.no-image {
|
||||
background-color: #ececec;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,27 +1,28 @@
|
|||
import { Component, forwardRef, Input } from '@angular/core'
|
||||
import { Component, forwardRef, Input, OnInit } from '@angular/core'
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
|
||||
import { ServerService } from '@app/core'
|
||||
|
||||
@Component({
|
||||
selector: 'my-image-upload',
|
||||
styleUrls: [ './image-upload.component.scss' ],
|
||||
templateUrl: './image-upload.component.html',
|
||||
selector: 'my-preview-upload',
|
||||
styleUrls: [ './preview-upload.component.scss' ],
|
||||
templateUrl: './preview-upload.component.html',
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ImageUploadComponent),
|
||||
useExisting: forwardRef(() => PreviewUploadComponent),
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class ImageUploadComponent implements ControlValueAccessor {
|
||||
export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
|
||||
@Input() inputLabel: string
|
||||
@Input() inputName: string
|
||||
@Input() previewWidth: string
|
||||
@Input() previewHeight: string
|
||||
|
||||
imageSrc: SafeResourceUrl
|
||||
allowedExtensionsMessage = ''
|
||||
|
||||
private file: File
|
||||
|
||||
|
@ -38,6 +39,10 @@ export class ImageUploadComponent implements ControlValueAccessor {
|
|||
return this.serverService.getConfig().video.image.size.max
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
|
||||
}
|
||||
|
||||
onFileChanged (file: File) {
|
||||
this.file = file
|
||||
|
|
@ -69,7 +69,7 @@ import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/sha
|
|||
import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
|
||||
import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
|
||||
import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
|
||||
import { ImageUploadComponent } from '@app/shared/images/image-upload.component'
|
||||
import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component'
|
||||
import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
|
||||
import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
|
||||
import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
|
||||
|
@ -154,7 +154,7 @@ import { ClipboardModule } from 'ngx-clipboard'
|
|||
ConfirmComponent,
|
||||
|
||||
GlobalIconComponent,
|
||||
ImageUploadComponent
|
||||
PreviewUploadComponent
|
||||
],
|
||||
|
||||
exports: [
|
||||
|
@ -218,7 +218,7 @@ import { ClipboardModule } from 'ngx-clipboard'
|
|||
ConfirmComponent,
|
||||
|
||||
GlobalIconComponent,
|
||||
ImageUploadComponent,
|
||||
PreviewUploadComponent,
|
||||
|
||||
NumberFormatterPipe,
|
||||
ObjectLengthPipe,
|
||||
|
|
|
@ -85,6 +85,11 @@ export class VideoEdit implements VideoUpdate {
|
|||
const originallyPublishedAt = new Date(values['originallyPublishedAt'])
|
||||
this.originallyPublishedAt = originallyPublishedAt.toISOString()
|
||||
}
|
||||
|
||||
// Use the same file than the preview for the thumbnail
|
||||
if (this.previewfile) {
|
||||
this.thumbnailfile = this.previewfile
|
||||
}
|
||||
}
|
||||
|
||||
toFormPatch () {
|
||||
|
|
|
@ -187,18 +187,14 @@
|
|||
<ng-template ngbTabContent>
|
||||
<div class="row advanced-settings">
|
||||
<div class="col-md-12 col-xl-8">
|
||||
<div class="form-group">
|
||||
<my-image-upload
|
||||
i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
|
||||
previewWidth="200px" previewHeight="110px"
|
||||
></my-image-upload>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-image-upload
|
||||
i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile"
|
||||
<label i18n for="previewfile">Video preview</label>
|
||||
|
||||
<my-preview-upload
|
||||
i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
|
||||
previewWidth="360px" previewHeight="200px"
|
||||
></my-image-upload>
|
||||
></my-preview-upload>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
|
@ -100,7 +100,6 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
language: this.videoValidatorsService.VIDEO_LANGUAGE,
|
||||
description: this.videoValidatorsService.VIDEO_DESCRIPTION,
|
||||
tags: null,
|
||||
thumbnailfile: null,
|
||||
previewfile: null,
|
||||
support: this.videoValidatorsService.VIDEO_SUPPORT,
|
||||
schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT,
|
||||
|
|
|
@ -26,6 +26,27 @@
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="isUploadingAudioFile">
|
||||
<div class="form-group audio-preview">
|
||||
<label i18n for="previewfileUpload">Video background image</label>
|
||||
|
||||
<div i18n class="audio-image-info">
|
||||
Image that will be merged with your audio file.
|
||||
<br />
|
||||
The chosen image will be definitive and cannot be modified.
|
||||
</div>
|
||||
|
||||
<my-preview-upload
|
||||
i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="previewfileUpload"
|
||||
previewWidth="360px" previewHeight="200px"
|
||||
></my-preview-upload>
|
||||
</div>
|
||||
|
||||
<div class="form-group upload-audio-button">
|
||||
<my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
@import 'variables';
|
||||
@import 'mixins';
|
||||
|
||||
.first-step-block .form-group-channel {
|
||||
margin-bottom: 20px;
|
||||
margin-top: 35px;
|
||||
.first-step-block {
|
||||
|
||||
.form-group-channel {
|
||||
margin-bottom: 20px;
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
.audio-image-info {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.audio-preview {
|
||||
margin: 30px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress-cancel {
|
||||
|
|
|
@ -35,8 +35,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
userVideoQuotaUsed = 0
|
||||
userVideoQuotaUsedDaily = 0
|
||||
|
||||
isUploadingAudioFile = false
|
||||
isUploadingVideo = false
|
||||
isUpdatingVideo = false
|
||||
|
||||
videoUploaded = false
|
||||
videoUploadObservable: Subscription = null
|
||||
videoUploadPercents = 0
|
||||
|
@ -44,7 +46,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
id: 0,
|
||||
uuid: ''
|
||||
}
|
||||
|
||||
waitTranscodingEnabled = true
|
||||
previewfileUpload: File
|
||||
|
||||
error: string
|
||||
|
||||
|
@ -100,6 +104,17 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
}
|
||||
}
|
||||
|
||||
getVideoFile () {
|
||||
return this.videofileInput.nativeElement.files[0]
|
||||
}
|
||||
|
||||
getAudioUploadLabel () {
|
||||
const videofile = this.getVideoFile()
|
||||
if (!videofile) return this.i18n('Upload')
|
||||
|
||||
return this.i18n('Upload {{videofileName}}', { videofileName: videofile.name })
|
||||
}
|
||||
|
||||
fileChange () {
|
||||
this.uploadFirstStep()
|
||||
}
|
||||
|
@ -114,38 +129,15 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
}
|
||||
}
|
||||
|
||||
uploadFirstStep () {
|
||||
const videofile = this.videofileInput.nativeElement.files[0]
|
||||
uploadFirstStep (clickedOnButton = false) {
|
||||
const videofile = this.getVideoFile()
|
||||
if (!videofile) return
|
||||
|
||||
// Check global user quota
|
||||
const bytePipes = new BytesPipe()
|
||||
const videoQuota = this.authService.getUser().videoQuota
|
||||
if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
|
||||
const msg = this.i18n(
|
||||
'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})',
|
||||
{
|
||||
videoSize: bytePipes.transform(videofile.size, 0),
|
||||
videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
|
||||
videoQuota: bytePipes.transform(videoQuota, 0)
|
||||
}
|
||||
)
|
||||
this.notifier.error(msg)
|
||||
return
|
||||
}
|
||||
if (!this.checkGlobalUserQuota(videofile)) return
|
||||
if (!this.checkDailyUserQuota(videofile)) return
|
||||
|
||||
// Check daily user quota
|
||||
const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
|
||||
if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
|
||||
const msg = this.i18n(
|
||||
'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})',
|
||||
{
|
||||
videoSize: bytePipes.transform(videofile.size, 0),
|
||||
quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0),
|
||||
quotaDaily: bytePipes.transform(videoQuotaDaily, 0)
|
||||
}
|
||||
)
|
||||
this.notifier.error(msg)
|
||||
if (clickedOnButton === false && this.isAudioFile(videofile.name)) {
|
||||
this.isUploadingAudioFile = true
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -180,6 +172,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
formData.append('channelId', '' + channelId)
|
||||
formData.append('videofile', videofile)
|
||||
|
||||
if (this.previewfileUpload) {
|
||||
formData.append('previewfile', this.previewfileUpload)
|
||||
formData.append('thumbnailfile', this.previewfileUpload)
|
||||
}
|
||||
|
||||
this.isUploadingVideo = true
|
||||
this.firstStepDone.emit(name)
|
||||
|
||||
|
@ -187,7 +184,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
name,
|
||||
privacy,
|
||||
nsfw,
|
||||
channelId
|
||||
channelId,
|
||||
previewfile: this.previewfileUpload
|
||||
})
|
||||
|
||||
this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
|
||||
|
@ -251,4 +249,52 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
private checkGlobalUserQuota (videofile: File) {
|
||||
const bytePipes = new BytesPipe()
|
||||
|
||||
// Check global user quota
|
||||
const videoQuota = this.authService.getUser().videoQuota
|
||||
if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
|
||||
const msg = this.i18n(
|
||||
'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})',
|
||||
{
|
||||
videoSize: bytePipes.transform(videofile.size, 0),
|
||||
videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
|
||||
videoQuota: bytePipes.transform(videoQuota, 0)
|
||||
}
|
||||
)
|
||||
this.notifier.error(msg)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private checkDailyUserQuota (videofile: File) {
|
||||
const bytePipes = new BytesPipe()
|
||||
|
||||
// Check daily user quota
|
||||
const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
|
||||
if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
|
||||
const msg = this.i18n(
|
||||
'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})',
|
||||
{
|
||||
videoSize: bytePipes.transform(videofile.size, 0),
|
||||
quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0),
|
||||
quotaDaily: bytePipes.transform(videoQuotaDaily, 0)
|
||||
}
|
||||
)
|
||||
this.notifier.error(msg)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private isAudioFile (filename: string) {
|
||||
return filename.endsWith('.mp3') || filename.endsWith('.flac') || filename.endsWith('.ogg')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -545,8 +545,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
private flushPlayer () {
|
||||
// Remove player if it exists
|
||||
if (this.player) {
|
||||
this.player.dispose()
|
||||
this.player = undefined
|
||||
try {
|
||||
this.player.dispose()
|
||||
this.player = undefined
|
||||
} catch (err) {
|
||||
console.error('Cannot dispose player.', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -117,8 +117,17 @@ export class PeertubePlayerManager {
|
|||
videojs(options.common.playerElement, videojsOptions, function (this: any) {
|
||||
const player = this
|
||||
|
||||
player.tech_.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options))
|
||||
player.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options))
|
||||
let alreadyFallback = false
|
||||
|
||||
player.tech_.one('error', () => {
|
||||
if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
|
||||
alreadyFallback = true
|
||||
})
|
||||
|
||||
player.one('error', () => {
|
||||
if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
|
||||
alreadyFallback = true
|
||||
})
|
||||
|
||||
self.addContextMenu(mode, player, options.common.embedUrl)
|
||||
|
||||
|
|
|
@ -180,6 +180,8 @@ transcoding:
|
|||
enabled: true
|
||||
# Allow your users to upload .mkv, .mov, .avi, .flv videos
|
||||
allow_additional_extensions: true
|
||||
# If a user uploads an audio file, PeerTube will create a video by merging the preview file and the audio file
|
||||
allow_audio_files: true
|
||||
threads: 1
|
||||
resolutions: # Only created if the original video has a higher resolution, uses more storage!
|
||||
240p: false
|
||||
|
|
|
@ -188,6 +188,8 @@ transcoding:
|
|||
enabled: true
|
||||
# Allow your users to upload .mkv, .mov, .avi, .flv videos
|
||||
allow_additional_extensions: true
|
||||
# If a user uploads an audio file, PeerTube will create a video by merging the preview file and the audio file
|
||||
allow_audio_files: true
|
||||
threads: 1
|
||||
resolutions: # Only created if the original video has a higher resolution, uses more storage!
|
||||
240p: false
|
||||
|
|
|
@ -31,3 +31,4 @@ signup:
|
|||
transcoding:
|
||||
enabled: true
|
||||
allow_additional_extensions: true
|
||||
allow_audio_files: true
|
||||
|
|
|
@ -55,6 +55,7 @@ signup:
|
|||
transcoding:
|
||||
enabled: true
|
||||
allow_additional_extensions: false
|
||||
allow_audio_files: false
|
||||
threads: 2
|
||||
resolutions:
|
||||
240p: true
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as program from 'commander'
|
|||
import { VideoModel } from '../server/models/video/video'
|
||||
import { initDatabaseModels } from '../server/initializers'
|
||||
import { JobQueue } from '../server/lib/job-queue'
|
||||
import { VideoTranscodingPayload } from '../server/lib/job-queue/handlers/video-transcoding'
|
||||
|
||||
program
|
||||
.option('-v, --video [videoUUID]', 'Video UUID')
|
||||
|
@ -31,15 +32,9 @@ async function run () {
|
|||
const video = await VideoModel.loadByUUIDWithFile(program['video'])
|
||||
if (!video) throw new Error('Video not found.')
|
||||
|
||||
const dataInput = {
|
||||
videoUUID: video.uuid,
|
||||
isNewVideo: false,
|
||||
resolution: undefined
|
||||
}
|
||||
|
||||
if (program.resolution !== undefined) {
|
||||
dataInput.resolution = program.resolution
|
||||
}
|
||||
const dataInput: VideoTranscodingPayload = program.resolution !== undefined
|
||||
? { type: 'new-resolution' as 'new-resolution', videoUUID: video.uuid, isNewVideo: false, resolution: program.resolution }
|
||||
: { type: 'optimize' as 'optimize', videoUUID: video.uuid, isNewVideo: false }
|
||||
|
||||
await JobQueue.Instance.init()
|
||||
await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
|
@ -255,6 +255,7 @@ function customConfig (): CustomConfig {
|
|||
transcoding: {
|
||||
enabled: CONFIG.TRANSCODING.ENABLED,
|
||||
allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
|
||||
allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES,
|
||||
threads: CONFIG.TRANSCODING.THREADS,
|
||||
resolutions: {
|
||||
'240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ],
|
||||
|
|
|
@ -6,7 +6,14 @@ import { logger } from '../../../helpers/logger'
|
|||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||
import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
|
||||
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
|
||||
import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
|
||||
import {
|
||||
DEFAULT_AUDIO_RESOLUTION,
|
||||
MIMETYPES,
|
||||
VIDEO_CATEGORIES,
|
||||
VIDEO_LANGUAGES,
|
||||
VIDEO_LICENCES,
|
||||
VIDEO_PRIVACIES
|
||||
} from '../../../initializers/constants'
|
||||
import {
|
||||
changeVideoChannelShare,
|
||||
federateVideoIfNeeded,
|
||||
|
@ -54,6 +61,7 @@ import { CONFIG } from '../../../initializers/config'
|
|||
import { sequelizeTypescript } from '../../../initializers/database'
|
||||
import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
|
||||
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
|
||||
import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding'
|
||||
|
||||
const auditLogger = auditLoggerFactory('videos')
|
||||
const videosRouter = express.Router()
|
||||
|
@ -191,18 +199,19 @@ async function addVideo (req: express.Request, res: express.Response) {
|
|||
const video = new VideoModel(videoData)
|
||||
video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
|
||||
|
||||
// Build the file object
|
||||
const { videoFileResolution } = await getVideoFileResolution(videoPhysicalFile.path)
|
||||
const fps = await getVideoFileFPS(videoPhysicalFile.path)
|
||||
|
||||
const videoFileData = {
|
||||
extname: extname(videoPhysicalFile.filename),
|
||||
resolution: videoFileResolution,
|
||||
size: videoPhysicalFile.size,
|
||||
fps
|
||||
size: videoPhysicalFile.size
|
||||
}
|
||||
const videoFile = new VideoFileModel(videoFileData)
|
||||
|
||||
if (!videoFile.isAudio()) {
|
||||
videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
|
||||
videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
|
||||
} else {
|
||||
videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
|
||||
}
|
||||
|
||||
// Move physical file
|
||||
const videoDir = CONFIG.STORAGE.VIDEOS_DIR
|
||||
const destination = join(videoDir, video.getVideoFilename(videoFile))
|
||||
|
@ -279,9 +288,21 @@ async function addVideo (req: express.Request, res: express.Response) {
|
|||
|
||||
if (video.state === VideoState.TO_TRANSCODE) {
|
||||
// Put uuid because we don't have id auto incremented for now
|
||||
const dataInput = {
|
||||
videoUUID: videoCreated.uuid,
|
||||
isNewVideo: true
|
||||
let dataInput: VideoTranscodingPayload
|
||||
|
||||
if (videoFile.isAudio()) {
|
||||
dataInput = {
|
||||
type: 'merge-audio' as 'merge-audio',
|
||||
resolution: DEFAULT_AUDIO_RESOLUTION,
|
||||
videoUUID: videoCreated.uuid,
|
||||
isNewVideo: true
|
||||
}
|
||||
} else {
|
||||
dataInput = {
|
||||
type: 'optimize' as 'optimize',
|
||||
videoUUID: videoCreated.uuid,
|
||||
isNewVideo: true
|
||||
}
|
||||
}
|
||||
|
||||
await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
|
||||
|
|
|
@ -181,7 +181,7 @@ async function getVideoCaption (req: express.Request, res: express.Response) {
|
|||
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE })
|
||||
}
|
||||
|
||||
async function generateNodeinfo (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
async function generateNodeinfo (req: express.Request, res: express.Response) {
|
||||
const { totalVideos } = await VideoModel.getStats()
|
||||
const { totalLocalVideoComments } = await VideoCommentModel.getStats()
|
||||
const { totalUsers } = await UserModel.getStats()
|
||||
|
|
|
@ -74,7 +74,18 @@ function createReqFiles (
|
|||
},
|
||||
|
||||
filename: async (req, file, cb) => {
|
||||
const extension = mimeTypes[ file.mimetype ] || extname(file.originalname)
|
||||
let extension: string
|
||||
const fileExtension = extname(file.originalname)
|
||||
const extensionFromMimetype = mimeTypes[ file.mimetype ]
|
||||
|
||||
// Take the file extension if we don't understand the mime type
|
||||
// We have the OGG/OGV exception too because firefox sends a bad mime type when sending an OGG file
|
||||
if (fileExtension === '.ogg' || fileExtension === '.ogv' || !extensionFromMimetype) {
|
||||
extension = fileExtension
|
||||
} else {
|
||||
extension = extensionFromMimetype
|
||||
}
|
||||
|
||||
let randomString = ''
|
||||
|
||||
try {
|
||||
|
|
|
@ -117,37 +117,50 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
|
|||
}
|
||||
}
|
||||
|
||||
type TranscodeOptions = {
|
||||
type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio'
|
||||
|
||||
interface BaseTranscodeOptions {
|
||||
type: TranscodeOptionsType
|
||||
inputPath: string
|
||||
outputPath: string
|
||||
resolution: VideoResolution
|
||||
isPortraitMode?: boolean
|
||||
doQuickTranscode?: Boolean
|
||||
}
|
||||
|
||||
hlsPlaylist?: {
|
||||
interface HLSTranscodeOptions extends BaseTranscodeOptions {
|
||||
type: 'hls'
|
||||
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
|
||||
}
|
||||
|
||||
type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions
|
||||
|
||||
function transcode (options: TranscodeOptions) {
|
||||
return new Promise<void>(async (res, rej) => {
|
||||
try {
|
||||
let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
|
||||
.output(options.outputPath)
|
||||
|
||||
if (options.doQuickTranscode) {
|
||||
if (options.hlsPlaylist) {
|
||||
throw(Error("Quick transcode and HLS can't be used at the same time"))
|
||||
}
|
||||
|
||||
command
|
||||
.format('mp4')
|
||||
.addOption('-c:v copy')
|
||||
.addOption('-c:a copy')
|
||||
.outputOption('-map_metadata -1') // strip all metadata
|
||||
.outputOption('-movflags faststart')
|
||||
} else if (options.hlsPlaylist) {
|
||||
if (options.type === 'quick-transcode') {
|
||||
command = await buildQuickTranscodeCommand(command)
|
||||
} else if (options.type === 'hls') {
|
||||
command = await buildHLSCommand(command, options)
|
||||
} else if (options.type === 'merge-audio') {
|
||||
command = await buildAudioMergeCommand(command, options)
|
||||
} else {
|
||||
command = await buildx264Command(command, options)
|
||||
}
|
||||
|
@ -163,7 +176,7 @@ function transcode (options: TranscodeOptions) {
|
|||
return rej(err)
|
||||
})
|
||||
.on('end', () => {
|
||||
return onTranscodingSuccess(options)
|
||||
return fixHLSPlaylistIfNeeded(options)
|
||||
.then(() => res())
|
||||
.catch(err => rej(err))
|
||||
})
|
||||
|
@ -205,6 +218,8 @@ export {
|
|||
getVideoFileResolution,
|
||||
getDurationFromVideoFile,
|
||||
generateImageFromVideoFile,
|
||||
TranscodeOptions,
|
||||
TranscodeOptionsType,
|
||||
transcode,
|
||||
getVideoFileFPS,
|
||||
computeResolutionsToTranscode,
|
||||
|
@ -215,7 +230,7 @@ export {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
|
||||
async function buildx264Command (command: ffmpeg.FfmpegCommand, options: VideoTranscodeOptions) {
|
||||
let fps = await getVideoFileFPS(options.inputPath)
|
||||
// On small/medium resolutions, limit FPS
|
||||
if (
|
||||
|
@ -226,7 +241,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
|
|||
fps = VIDEO_TRANSCODING_FPS.AVERAGE
|
||||
}
|
||||
|
||||
command = await presetH264(command, options.resolution, fps)
|
||||
command = await presetH264(command, options.inputPath, options.resolution, fps)
|
||||
|
||||
if (options.resolution !== undefined) {
|
||||
// '?x720' or '720x?' for example
|
||||
|
@ -245,7 +260,29 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
|
|||
return command
|
||||
}
|
||||
|
||||
async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
|
||||
async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
|
||||
command = command.loop(undefined)
|
||||
|
||||
command = await presetH264VeryFast(command, options.audioPath, options.resolution)
|
||||
|
||||
command = command.input(options.audioPath)
|
||||
.videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
|
||||
.outputOption('-tune stillimage')
|
||||
.outputOption('-shortest')
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
|
||||
command = await presetCopy(command)
|
||||
|
||||
command = command.outputOption('-map_metadata -1') // strip all metadata
|
||||
.outputOption('-movflags faststart')
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
|
||||
const videoPath = getHLSVideoPath(options)
|
||||
|
||||
command = await presetCopy(command)
|
||||
|
@ -261,19 +298,19 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod
|
|||
return command
|
||||
}
|
||||
|
||||
function getHLSVideoPath (options: TranscodeOptions) {
|
||||
function getHLSVideoPath (options: HLSTranscodeOptions) {
|
||||
return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
|
||||
}
|
||||
|
||||
async function onTranscodingSuccess (options: TranscodeOptions) {
|
||||
if (!options.hlsPlaylist) return
|
||||
async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
|
||||
if (options.type !== 'hls') return
|
||||
|
||||
// Fix wrong mapping with some ffmpeg versions
|
||||
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}",`)
|
||||
|
||||
|
@ -300,44 +337,27 @@ function getVideoStreamFromFile (path: string) {
|
|||
* and quality. Superfast and ultrafast will give you better
|
||||
* performance, but then quality is noticeably worse.
|
||||
*/
|
||||
async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
|
||||
let localCommand = await presetH264(command, resolution, fps)
|
||||
async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
|
||||
let localCommand = await presetH264(command, input, resolution, fps)
|
||||
|
||||
localCommand = localCommand.outputOption('-preset:v veryfast')
|
||||
.outputOption([ '--aq-mode=2', '--aq-strength=1.3' ])
|
||||
|
||||
/*
|
||||
MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
|
||||
Our target situation is closer to a livestream than a stream,
|
||||
since we want to reduce as much a possible the encoding burden,
|
||||
altough not to the point of a livestream where there is a hard
|
||||
although not to the point of a livestream where there is a hard
|
||||
constraint on the frames per second to be encoded.
|
||||
|
||||
why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
|
||||
Make up for most of the loss of grain and macroblocking
|
||||
with less computing power.
|
||||
*/
|
||||
|
||||
return localCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset optimised for a stillimage audio video
|
||||
*/
|
||||
async function presetStillImageWithAudio (
|
||||
command: ffmpeg.FfmpegCommand,
|
||||
resolution: VideoResolution,
|
||||
fps: number
|
||||
): Promise<ffmpeg.FfmpegCommand> {
|
||||
let localCommand = await presetH264VeryFast(command, resolution, fps)
|
||||
localCommand = localCommand.outputOption('-tune stillimage')
|
||||
|
||||
return localCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* A toolbox to play with audio
|
||||
*/
|
||||
namespace audio {
|
||||
export const get = (option: ffmpeg.FfmpegCommand | string) => {
|
||||
export const get = (option: string) => {
|
||||
// without position, ffprobe considers the last input only
|
||||
// we make it consider the first input only
|
||||
// if you pass a file path to pos, then ffprobe acts on that file directly
|
||||
|
@ -359,11 +379,7 @@ namespace audio {
|
|||
return res({ absolutePath: data.format.filename })
|
||||
}
|
||||
|
||||
if (typeof option === 'string') {
|
||||
return ffmpeg.ffprobe(option, parseFfprobe)
|
||||
}
|
||||
|
||||
return option.ffprobe(parseFfprobe)
|
||||
return ffmpeg.ffprobe(option, parseFfprobe)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -405,7 +421,7 @@ namespace audio {
|
|||
* As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
|
||||
* See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
|
||||
*/
|
||||
async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> {
|
||||
async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
|
||||
let localCommand = command
|
||||
.format('mp4')
|
||||
.videoCodec('libx264')
|
||||
|
@ -416,7 +432,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
|
|||
.outputOption('-map_metadata -1') // strip all metadata
|
||||
.outputOption('-movflags faststart')
|
||||
|
||||
const parsedAudio = await audio.get(localCommand)
|
||||
const parsedAudio = await audio.get(input)
|
||||
|
||||
if (!parsedAudio.audioStream) {
|
||||
localCommand = localCommand.noAudio()
|
||||
|
@ -425,28 +441,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
|
|||
.audioCodec('libfdk_aac')
|
||||
.audioQuality(5)
|
||||
} else {
|
||||
// we try to reduce the ceiling bitrate by making rough correspondances of bitrates
|
||||
// we try to reduce the ceiling bitrate by making rough matches of bitrates
|
||||
// of course this is far from perfect, but it might save some space in the end
|
||||
const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
|
||||
let bitrate: number
|
||||
if (audio.bitrate[ audioCodecName ]) {
|
||||
localCommand = localCommand.audioCodec('aac')
|
||||
localCommand = localCommand.audioCodec('aac')
|
||||
|
||||
bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
|
||||
const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
|
||||
|
||||
if (audio.bitrate[ audioCodecName ]) {
|
||||
const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
|
||||
if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
|
||||
}
|
||||
}
|
||||
|
||||
// Constrained Encoding (VBV)
|
||||
// https://slhck.info/video/2017/03/01/rate-control.html
|
||||
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||
const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
|
||||
localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`])
|
||||
if (fps) {
|
||||
// Constrained Encoding (VBV)
|
||||
// https://slhck.info/video/2017/03/01/rate-control.html
|
||||
// https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
|
||||
const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
|
||||
localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
|
||||
|
||||
// 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
|
||||
localCommand = localCommand.outputOption(`-g ${ fps * 2 }`)
|
||||
// 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
|
||||
localCommand = localCommand.outputOption(`-g ${fps * 2}`)
|
||||
}
|
||||
|
||||
return localCommand
|
||||
}
|
||||
|
|
|
@ -148,6 +148,7 @@ const CONFIG = {
|
|||
TRANSCODING: {
|
||||
get ENABLED () { return config.get<boolean>('transcoding.enabled') },
|
||||
get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') },
|
||||
get ALLOW_AUDIO_FILES () { return config.get<boolean>('transcoding.allow_audio_files') },
|
||||
get THREADS () { return config.get<number>('transcoding.threads') },
|
||||
RESOLUTIONS: {
|
||||
get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') },
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { join } from 'path'
|
||||
import { JobType, VideoRateType, VideoState } from '../../shared/models'
|
||||
import { JobType, VideoRateType, VideoResolution, VideoState } from '../../shared/models'
|
||||
import { ActivityPubActorType } from '../../shared/models/activitypub'
|
||||
import { FollowState } from '../../shared/models/actors'
|
||||
import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos'
|
||||
// Do not use barrels, remain constants as independent as possible
|
||||
import { isTestInstance, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
|
||||
import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils'
|
||||
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
|
||||
import { invert } from 'lodash'
|
||||
import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
|
||||
|
@ -228,7 +228,7 @@ let CONSTRAINTS_FIELDS = {
|
|||
max: 2 * 1024 * 1024 // 2MB
|
||||
}
|
||||
},
|
||||
EXTNAME: buildVideosExtname(),
|
||||
EXTNAME: [] as string[],
|
||||
INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2
|
||||
DURATION: { min: 0 }, // Number
|
||||
TAGS: { min: 0, max: 5 }, // Number of total tags
|
||||
|
@ -300,6 +300,8 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
|
|||
KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P
|
||||
|
||||
const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = {
|
||||
LIKE: 'like',
|
||||
DISLIKE: 'dislike'
|
||||
|
@ -380,8 +382,18 @@ const VIDEO_PLAYLIST_TYPES = {
|
|||
}
|
||||
|
||||
const MIMETYPES = {
|
||||
AUDIO: {
|
||||
MIMETYPE_EXT: {
|
||||
'audio/mpeg': '.mp3',
|
||||
'audio/mp3': '.mp3',
|
||||
'application/ogg': '.ogg',
|
||||
'audio/ogg': '.ogg',
|
||||
'audio/flac': '.flac'
|
||||
},
|
||||
EXT_MIMETYPE: null as { [ id: string ]: string }
|
||||
},
|
||||
VIDEO: {
|
||||
MIMETYPE_EXT: buildVideoMimetypeExt(),
|
||||
MIMETYPE_EXT: null as { [ id: string ]: string },
|
||||
EXT_MIMETYPE: null as { [ id: string ]: string }
|
||||
},
|
||||
IMAGE: {
|
||||
|
@ -403,7 +415,7 @@ const MIMETYPES = {
|
|||
}
|
||||
}
|
||||
}
|
||||
MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
|
||||
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -429,7 +441,7 @@ const ACTIVITY_PUB = {
|
|||
COLLECTION_ITEMS_PER_PAGE: 10,
|
||||
FETCH_PAGE_LIMIT: 100,
|
||||
URL_MIME_TYPES: {
|
||||
VIDEO: Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT),
|
||||
VIDEO: [] as string[],
|
||||
TORRENT: [ 'application/x-bittorrent' ],
|
||||
MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ]
|
||||
},
|
||||
|
@ -497,8 +509,8 @@ const THUMBNAILS_SIZE = {
|
|||
height: 122
|
||||
}
|
||||
const PREVIEWS_SIZE = {
|
||||
width: 560,
|
||||
height: 315
|
||||
width: 850,
|
||||
height: 480
|
||||
}
|
||||
const AVATARS_SIZE = {
|
||||
width: 120,
|
||||
|
@ -543,6 +555,10 @@ const REDUNDANCY = {
|
|||
|
||||
const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
|
||||
|
||||
const ASSETS_PATH = {
|
||||
DEFAULT_AUDIO_BACKGROUND: join(root(), 'server', 'assets', 'default-audio-background.jpg')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CUSTOM_HTML_TAG_COMMENTS = {
|
||||
|
@ -612,6 +628,7 @@ if (isTestInstance() === true) {
|
|||
}
|
||||
|
||||
updateWebserverUrls()
|
||||
updateWebserverConfig()
|
||||
|
||||
registerConfigChangedHandler(() => {
|
||||
updateWebserverUrls()
|
||||
|
@ -681,12 +698,14 @@ export {
|
|||
RATES_LIMIT,
|
||||
MIMETYPES,
|
||||
CRAWL_REQUEST_CONCURRENCY,
|
||||
DEFAULT_AUDIO_RESOLUTION,
|
||||
JOB_COMPLETED_LIFETIME,
|
||||
HTTP_SIGNATURE,
|
||||
VIDEO_IMPORT_STATES,
|
||||
VIDEO_VIEW_LIFETIME,
|
||||
CONTACT_FORM_LIFETIME,
|
||||
VIDEO_PLAYLIST_PRIVACIES,
|
||||
ASSETS_PATH,
|
||||
loadLanguages,
|
||||
buildLanguages
|
||||
}
|
||||
|
@ -700,15 +719,21 @@ function buildVideoMimetypeExt () {
|
|||
'video/mp4': '.mp4'
|
||||
}
|
||||
|
||||
if (CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) {
|
||||
Object.assign(data, {
|
||||
'video/quicktime': '.mov',
|
||||
'video/x-msvideo': '.avi',
|
||||
'video/x-flv': '.flv',
|
||||
'video/x-matroska': '.mkv',
|
||||
'application/octet-stream': '.mkv',
|
||||
'video/avi': '.avi'
|
||||
})
|
||||
if (CONFIG.TRANSCODING.ENABLED) {
|
||||
if (CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) {
|
||||
Object.assign(data, {
|
||||
'video/quicktime': '.mov',
|
||||
'video/x-msvideo': '.avi',
|
||||
'video/x-flv': '.flv',
|
||||
'video/x-matroska': '.mkv',
|
||||
'application/octet-stream': '.mkv',
|
||||
'video/avi': '.avi'
|
||||
})
|
||||
}
|
||||
|
||||
if (CONFIG.TRANSCODING.ALLOW_AUDIO_FILES) {
|
||||
Object.assign(data, MIMETYPES.AUDIO.MIMETYPE_EXT)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
|
@ -724,16 +749,15 @@ function updateWebserverUrls () {
|
|||
}
|
||||
|
||||
function updateWebserverConfig () {
|
||||
CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname()
|
||||
|
||||
MIMETYPES.VIDEO.MIMETYPE_EXT = buildVideoMimetypeExt()
|
||||
MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
|
||||
ACTIVITY_PUB.URL_MIME_TYPES.VIDEO = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
|
||||
|
||||
CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname()
|
||||
}
|
||||
|
||||
function buildVideosExtname () {
|
||||
return CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS
|
||||
? [ '.mp4', '.ogv', '.webm', '.mkv', '.mov', '.avi', '.flv' ]
|
||||
: [ '.mp4', '.ogv', '.webm' ]
|
||||
return Object.keys(MIMETYPES.VIDEO.EXT_MIMETYPE)
|
||||
}
|
||||
|
||||
function loadLanguages () {
|
||||
|
|
|
@ -21,7 +21,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
|
|||
const video = await VideoModel.loadByUUIDWithFile(videoUUID)
|
||||
if (!video) return undefined
|
||||
|
||||
if (video.isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreview().filename) }
|
||||
if (video.isOwned()) return { isOwned: true, path: video.getPreview().getPath() }
|
||||
|
||||
return this.loadRemoteFile(videoUUID)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as Bull from 'bull'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { publishVideoIfNeeded } from './video-transcoding'
|
||||
import { publishNewResolutionIfNeeded } from './video-transcoding'
|
||||
import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
|
||||
import { copy, stat } from 'fs-extra'
|
||||
import { VideoFileModel } from '../../../models/video/video-file'
|
||||
|
@ -25,7 +25,7 @@ async function processVideoFileImport (job: Bull.Job) {
|
|||
|
||||
await updateVideoFile(video, payload.filePath)
|
||||
|
||||
await publishVideoIfNeeded(video)
|
||||
await publishNewResolutionIfNeeded(video)
|
||||
return video
|
||||
}
|
||||
|
||||
|
|
|
@ -209,6 +209,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
|
|||
if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) {
|
||||
// Put uuid because we don't have id auto incremented for now
|
||||
const dataInput = {
|
||||
type: 'optimize' as 'optimize',
|
||||
videoUUID: videoImportUpdated.Video.uuid,
|
||||
isNewVideo: true
|
||||
}
|
||||
|
|
|
@ -8,18 +8,39 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
|||
import { sequelizeTypescript } from '../../../initializers'
|
||||
import * as Bluebird from 'bluebird'
|
||||
import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
|
||||
import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding'
|
||||
import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile, mergeAudioVideofile } from '../../video-transcoding'
|
||||
import { Notifier } from '../../notifier'
|
||||
import { CONFIG } from '../../../initializers/config'
|
||||
|
||||
export type VideoTranscodingPayload = {
|
||||
interface BaseTranscodingPayload {
|
||||
videoUUID: string
|
||||
resolution?: VideoResolution
|
||||
isNewVideo?: boolean
|
||||
isPortraitMode?: boolean
|
||||
generateHlsPlaylist?: boolean
|
||||
}
|
||||
|
||||
interface HLSTranscodingPayload extends BaseTranscodingPayload {
|
||||
type: 'hls'
|
||||
isPortraitMode?: boolean
|
||||
resolution: VideoResolution
|
||||
}
|
||||
|
||||
interface NewResolutionTranscodingPayload extends BaseTranscodingPayload {
|
||||
type: 'new-resolution'
|
||||
isPortraitMode?: boolean
|
||||
resolution: VideoResolution
|
||||
}
|
||||
|
||||
interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
|
||||
type: 'merge-audio'
|
||||
resolution: VideoResolution
|
||||
}
|
||||
|
||||
interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
|
||||
type: 'optimize'
|
||||
}
|
||||
|
||||
export type VideoTranscodingPayload = HLSTranscodingPayload | NewResolutionTranscodingPayload
|
||||
| OptimizeTranscodingPayload | MergeAudioTranscodingPayload
|
||||
|
||||
async function processVideoTranscoding (job: Bull.Job) {
|
||||
const payload = job.data as VideoTranscodingPayload
|
||||
logger.info('Processing video file in job %d.', job.id)
|
||||
|
@ -31,14 +52,18 @@ async function processVideoTranscoding (job: Bull.Job) {
|
|||
return undefined
|
||||
}
|
||||
|
||||
if (payload.generateHlsPlaylist) {
|
||||
if (payload.type === 'hls') {
|
||||
await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false)
|
||||
|
||||
await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
|
||||
} else if (payload.resolution) { // Transcoding in other resolution
|
||||
} else if (payload.type === 'new-resolution') {
|
||||
await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
|
||||
|
||||
await retryTransactionWrapper(publishVideoIfNeeded, video, payload)
|
||||
await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload)
|
||||
} else if (payload.type === 'merge-audio') {
|
||||
await mergeAudioVideofile(video, payload.resolution)
|
||||
|
||||
await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload)
|
||||
} else {
|
||||
await optimizeVideofile(video)
|
||||
|
||||
|
@ -62,7 +87,7 @@ async function onHlsPlaylistGenerationSuccess (video: VideoModel) {
|
|||
})
|
||||
}
|
||||
|
||||
async function publishVideoIfNeeded (video: VideoModel, payload?: VideoTranscodingPayload) {
|
||||
async function publishNewResolutionIfNeeded (video: VideoModel, payload?: NewResolutionTranscodingPayload | MergeAudioTranscodingPayload) {
|
||||
const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
|
||||
// Maybe the video changed in database, refresh it
|
||||
let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
|
||||
|
@ -94,7 +119,7 @@ async function publishVideoIfNeeded (video: VideoModel, payload?: VideoTranscodi
|
|||
await createHlsJobIfEnabled(payload)
|
||||
}
|
||||
|
||||
async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: VideoTranscodingPayload) {
|
||||
async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: OptimizeTranscodingPayload) {
|
||||
if (videoArg === undefined) return undefined
|
||||
|
||||
// Outside the transaction (IO on disk)
|
||||
|
@ -120,6 +145,7 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: Video
|
|||
|
||||
for (const resolution of resolutionsEnabled) {
|
||||
const dataInput = {
|
||||
type: 'new-resolution' as 'new-resolution',
|
||||
videoUUID: videoDatabase.uuid,
|
||||
resolution
|
||||
}
|
||||
|
@ -149,27 +175,27 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: Video
|
|||
if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
|
||||
if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase)
|
||||
|
||||
await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }))
|
||||
const hlsPayload = Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution })
|
||||
await createHlsJobIfEnabled(hlsPayload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
processVideoTranscoding,
|
||||
publishVideoIfNeeded
|
||||
publishNewResolutionIfNeeded
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createHlsJobIfEnabled (payload?: VideoTranscodingPayload) {
|
||||
function createHlsJobIfEnabled (payload?: { videoUUID: string, resolution: number, isPortraitMode?: boolean }) {
|
||||
// Generate HLS playlist?
|
||||
if (payload && CONFIG.TRANSCODING.HLS.ENABLED) {
|
||||
const hlsTranscodingPayload = {
|
||||
type: 'hls' as 'hls',
|
||||
videoUUID: payload.videoUUID,
|
||||
resolution: payload.resolution,
|
||||
isPortraitMode: payload.isPortraitMode,
|
||||
|
||||
generateHlsPlaylist: true
|
||||
isPortraitMode: payload.isPortraitMode
|
||||
}
|
||||
|
||||
return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload })
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { VideoFileModel } from '../models/video/video-file'
|
||||
import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
|
||||
import { CONFIG } from '../initializers/config'
|
||||
import { PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
|
||||
import { PREVIEWS_SIZE, THUMBNAILS_SIZE, ASSETS_PATH } from '../initializers/constants'
|
||||
import { VideoModel } from '../models/video/video'
|
||||
import { ThumbnailModel } from '../models/video/thumbnail'
|
||||
import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
|
||||
|
@ -45,8 +45,10 @@ function createVideoMiniatureFromExisting (inputPath: string, video: VideoModel,
|
|||
function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) {
|
||||
const input = video.getVideoFilePath(videoFile)
|
||||
|
||||
const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type)
|
||||
const thumbnailCreator = () => generateImageFromVideoFile(input, basePath, filename, { height, width })
|
||||
const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type)
|
||||
const thumbnailCreator = videoFile.isAudio()
|
||||
? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true)
|
||||
: () => generateImageFromVideoFile(input, basePath, filename, { height, width })
|
||||
|
||||
return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants'
|
||||
import { join } from 'path'
|
||||
import { getVideoFileFPS, transcode, canDoQuickTranscode } from '../helpers/ffmpeg-utils'
|
||||
import { canDoQuickTranscode, getVideoFileFPS, transcode, TranscodeOptions, TranscodeOptionsType } from '../helpers/ffmpeg-utils'
|
||||
import { ensureDir, move, remove, stat } from 'fs-extra'
|
||||
import { logger } from '../helpers/logger'
|
||||
import { VideoResolution } from '../../shared/models/videos'
|
||||
|
@ -23,13 +23,15 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
|
|||
const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
|
||||
const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||
|
||||
const doQuickTranscode = await(canDoQuickTranscode(videoInputPath))
|
||||
const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath)
|
||||
? 'quick-transcode'
|
||||
: 'video'
|
||||
|
||||
const transcodeOptions = {
|
||||
const transcodeOptions: TranscodeOptions = {
|
||||
type: transcodeType as any, // FIXME: typing issue
|
||||
inputPath: videoInputPath,
|
||||
outputPath: videoTranscodedPath,
|
||||
resolution: inputVideoFile.resolution,
|
||||
doQuickTranscode
|
||||
resolution: inputVideoFile.resolution
|
||||
}
|
||||
|
||||
// Could be very long!
|
||||
|
@ -39,19 +41,11 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
|
|||
await remove(videoInputPath)
|
||||
|
||||
// Important to do this before getVideoFilename() to take in account the new file extension
|
||||
inputVideoFile.set('extname', newExtname)
|
||||
|
||||
const stats = await stat(videoTranscodedPath)
|
||||
const fps = await getVideoFileFPS(videoTranscodedPath)
|
||||
inputVideoFile.extname = newExtname
|
||||
|
||||
const videoOutputPath = video.getVideoFilePath(inputVideoFile)
|
||||
await move(videoTranscodedPath, videoOutputPath)
|
||||
|
||||
inputVideoFile.set('size', stats.size)
|
||||
inputVideoFile.set('fps', fps)
|
||||
|
||||
await video.createTorrentAndSetInfoHash(inputVideoFile)
|
||||
await inputVideoFile.save()
|
||||
await onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
|
||||
} catch (err) {
|
||||
// Auto destruction...
|
||||
video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
|
||||
|
@ -81,6 +75,7 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
|
|||
const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile))
|
||||
|
||||
const transcodeOptions = {
|
||||
type: 'video' as 'video',
|
||||
inputPath: videoInputPath,
|
||||
outputPath: videoTranscodedPath,
|
||||
resolution,
|
||||
|
@ -89,19 +84,37 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
|
|||
|
||||
await transcode(transcodeOptions)
|
||||
|
||||
const stats = await stat(videoTranscodedPath)
|
||||
const fps = await getVideoFileFPS(videoTranscodedPath)
|
||||
return onVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
|
||||
}
|
||||
|
||||
await move(videoTranscodedPath, videoOutputPath)
|
||||
async function mergeAudioVideofile (video: VideoModel, resolution: VideoResolution) {
|
||||
const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
|
||||
const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
|
||||
const newExtname = '.mp4'
|
||||
|
||||
newVideoFile.set('size', stats.size)
|
||||
newVideoFile.set('fps', fps)
|
||||
const inputVideoFile = video.getOriginalFile()
|
||||
|
||||
await video.createTorrentAndSetInfoHash(newVideoFile)
|
||||
const audioInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
|
||||
const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
|
||||
|
||||
await newVideoFile.save()
|
||||
const transcodeOptions = {
|
||||
type: 'merge-audio' as 'merge-audio',
|
||||
inputPath: video.getPreview().getPath(),
|
||||
outputPath: videoTranscodedPath,
|
||||
audioPath: audioInputPath,
|
||||
resolution
|
||||
}
|
||||
|
||||
video.VideoFiles.push(newVideoFile)
|
||||
await transcode(transcodeOptions)
|
||||
|
||||
await remove(audioInputPath)
|
||||
|
||||
// Important to do this before getVideoFilename() to take in account the new file extension
|
||||
inputVideoFile.extname = newExtname
|
||||
|
||||
const videoOutputPath = video.getVideoFilePath(inputVideoFile)
|
||||
|
||||
return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
|
||||
}
|
||||
|
||||
async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
|
||||
|
@ -112,6 +125,7 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti
|
|||
const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
|
||||
|
||||
const transcodeOptions = {
|
||||
type: 'hls' as 'hls',
|
||||
inputPath: videoInputPath,
|
||||
outputPath,
|
||||
resolution,
|
||||
|
@ -140,8 +154,34 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti
|
|||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
generateHlsPlaylist,
|
||||
optimizeVideofile,
|
||||
transcodeOriginalVideofile
|
||||
transcodeOriginalVideofile,
|
||||
mergeAudioVideofile
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function onVideoFileTranscoding (video: VideoModel, videoFile: VideoFileModel, transcodingPath: string, outputPath: string) {
|
||||
const stats = await stat(transcodingPath)
|
||||
const fps = await getVideoFileFPS(transcodingPath)
|
||||
|
||||
await move(transcodingPath, outputPath)
|
||||
|
||||
videoFile.set('size', stats.size)
|
||||
videoFile.set('fps', fps)
|
||||
|
||||
await video.createTorrentAndSetInfoHash(videoFile)
|
||||
|
||||
const updatedVideoFile = await videoFile.save()
|
||||
|
||||
// Add it if this is a new created file
|
||||
if (video.VideoFiles.some(f => f.id === videoFile.id) === false) {
|
||||
video.VideoFiles.push(updatedVideoFile)
|
||||
}
|
||||
|
||||
return video
|
||||
}
|
||||
|
|
|
@ -107,10 +107,12 @@ export class ThumbnailModel extends Model<ThumbnailModel> {
|
|||
return WEBSERVER.URL + staticPath + this.filename
|
||||
}
|
||||
|
||||
removeThumbnail () {
|
||||
getPath () {
|
||||
const directory = ThumbnailModel.types[this.type].directory
|
||||
const thumbnailPath = join(directory, this.filename)
|
||||
return join(directory, this.filename)
|
||||
}
|
||||
|
||||
return remove(thumbnailPath)
|
||||
removeThumbnail () {
|
||||
return remove(this.getPath())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { VideoModel } from './video'
|
|||
import { VideoRedundancyModel } from '../redundancy/video-redundancy'
|
||||
import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
|
||||
import { FindOptions, QueryTypes, Transaction } from 'sequelize'
|
||||
import { MIMETYPES } from '../../initializers/constants'
|
||||
|
||||
@Table({
|
||||
tableName: 'videoFile',
|
||||
|
@ -161,6 +162,10 @@ export class VideoFileModel extends Model<VideoFileModel> {
|
|||
}))
|
||||
}
|
||||
|
||||
isAudio () {
|
||||
return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname]
|
||||
}
|
||||
|
||||
hasSameUniqueKeysThan (other: VideoFileModel) {
|
||||
return this.fps === other.fps &&
|
||||
this.resolution === other.resolution &&
|
||||
|
|
|
@ -59,6 +59,7 @@ describe('Test config API validators', function () {
|
|||
transcoding: {
|
||||
enabled: true,
|
||||
allowAdditionalExtensions: true,
|
||||
allowAudioFiles: true,
|
||||
threads: 1,
|
||||
resolutions: {
|
||||
'240p': false,
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
registerUser,
|
||||
reRunServer, ServerInfo,
|
||||
setAccessTokensToServers,
|
||||
updateCustomConfig
|
||||
updateCustomConfig, uploadVideo
|
||||
} from '../../../../shared/extra-utils'
|
||||
import { ServerConfig } from '../../../../shared/models'
|
||||
|
||||
|
@ -52,6 +52,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
|
|||
expect(data.user.videoQuotaDaily).to.equal(-1)
|
||||
expect(data.transcoding.enabled).to.be.false
|
||||
expect(data.transcoding.allowAdditionalExtensions).to.be.false
|
||||
expect(data.transcoding.allowAudioFiles).to.be.false
|
||||
expect(data.transcoding.threads).to.equal(2)
|
||||
expect(data.transcoding.resolutions['240p']).to.be.true
|
||||
expect(data.transcoding.resolutions['360p']).to.be.true
|
||||
|
@ -102,6 +103,7 @@ function checkUpdatedConfig (data: CustomConfig) {
|
|||
expect(data.transcoding.enabled).to.be.true
|
||||
expect(data.transcoding.threads).to.equal(1)
|
||||
expect(data.transcoding.allowAdditionalExtensions).to.be.true
|
||||
expect(data.transcoding.allowAudioFiles).to.be.true
|
||||
expect(data.transcoding.resolutions['240p']).to.be.false
|
||||
expect(data.transcoding.resolutions['360p']).to.be.true
|
||||
expect(data.transcoding.resolutions['480p']).to.be.true
|
||||
|
@ -158,6 +160,9 @@ describe('Test config', function () {
|
|||
expect(data.video.file.extensions).to.contain('.webm')
|
||||
expect(data.video.file.extensions).to.contain('.ogv')
|
||||
|
||||
await uploadVideo(server.url, server.accessToken, { fixture: 'video_short.mkv' }, 400)
|
||||
await uploadVideo(server.url, server.accessToken, { fixture: 'sample.ogg' }, 400)
|
||||
|
||||
expect(data.contactForm.enabled).to.be.true
|
||||
})
|
||||
|
||||
|
@ -215,6 +220,7 @@ describe('Test config', function () {
|
|||
transcoding: {
|
||||
enabled: true,
|
||||
allowAdditionalExtensions: true,
|
||||
allowAudioFiles: true,
|
||||
threads: 1,
|
||||
resolutions: {
|
||||
'240p': false,
|
||||
|
@ -269,6 +275,12 @@ describe('Test config', function () {
|
|||
expect(data.video.file.extensions).to.contain('.ogv')
|
||||
expect(data.video.file.extensions).to.contain('.flv')
|
||||
expect(data.video.file.extensions).to.contain('.mkv')
|
||||
expect(data.video.file.extensions).to.contain('.mp3')
|
||||
expect(data.video.file.extensions).to.contain('.ogg')
|
||||
expect(data.video.file.extensions).to.contain('.flac')
|
||||
|
||||
await uploadVideo(server.url, server.accessToken, { fixture: 'video_short.mkv' }, 200)
|
||||
await uploadVideo(server.url, server.accessToken, { fixture: 'sample.ogg' }, 200)
|
||||
})
|
||||
|
||||
it('Should have the configuration updated after a restart', async function () {
|
||||
|
|
|
@ -26,7 +26,7 @@ describe('Test jobs', function () {
|
|||
})
|
||||
|
||||
it('Should create some jobs', async function () {
|
||||
this.timeout(30000)
|
||||
this.timeout(60000)
|
||||
|
||||
await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video1' })
|
||||
await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video2' })
|
||||
|
|
|
@ -5,5 +5,5 @@ set -eu
|
|||
serverFiles=$(find server/tests/api/server -type f | grep -v index.ts | xargs echo)
|
||||
usersFiles=$(find server/tests/api/users -type f | grep -v index.ts | xargs echo)
|
||||
|
||||
MOCHA_PARALLEL=true mocha-parallel-tests --max-parallel $1 --timeout 5000 --exit --require ts-node/register --bail \
|
||||
MOCHA_PARALLEL=true mocha --timeout 5000 --exit --require ts-node/register --bail \
|
||||
$serverFiles $usersFiles
|
||||
|
|
|
@ -215,7 +215,7 @@ describe('Test multiple servers', function () {
|
|||
files: [
|
||||
{
|
||||
resolution: 240,
|
||||
size: 187000
|
||||
size: 189000
|
||||
},
|
||||
{
|
||||
resolution: 360,
|
||||
|
@ -223,7 +223,7 @@ describe('Test multiple servers', function () {
|
|||
},
|
||||
{
|
||||
resolution: 480,
|
||||
size: 383000
|
||||
size: 384000
|
||||
},
|
||||
{
|
||||
resolution: 720,
|
||||
|
|
|
@ -41,8 +41,8 @@ describe('Test services', function () {
|
|||
expect(res.body.width).to.equal(560)
|
||||
expect(res.body.height).to.equal(315)
|
||||
expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl)
|
||||
expect(res.body.thumbnail_width).to.equal(560)
|
||||
expect(res.body.thumbnail_height).to.equal(315)
|
||||
expect(res.body.thumbnail_width).to.equal(850)
|
||||
expect(res.body.thumbnail_height).to.equal(480)
|
||||
})
|
||||
|
||||
it('Should have a valid oEmbed response with small max height query', async function () {
|
||||
|
|
|
@ -21,12 +21,11 @@ import {
|
|||
import { VideoDetails } from '../../../../shared/models/videos'
|
||||
import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
|
||||
import { join } from 'path'
|
||||
import { DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants'
|
||||
|
||||
const expect = chai.expect
|
||||
|
||||
async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
|
||||
const resolutions = [ 240, 360, 480, 720 ]
|
||||
|
||||
async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string, resolutions = [ 240, 360, 480, 720 ]) {
|
||||
for (const server of servers) {
|
||||
const res = await getVideo(server.url, videoUUID)
|
||||
const videoDetails: VideoDetails = res.body
|
||||
|
@ -41,9 +40,8 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
|
|||
|
||||
const masterPlaylist = res2.text
|
||||
|
||||
expect(masterPlaylist).to.contain('#EXT-X-STREAM-INF:BANDWIDTH=55472,RESOLUTION=640x360,FRAME-RATE=25')
|
||||
|
||||
for (const resolution of resolutions) {
|
||||
expect(masterPlaylist).to.match(new RegExp('#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + ',FRAME-RATE=\\d+'))
|
||||
expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
|
||||
}
|
||||
}
|
||||
|
@ -70,11 +68,21 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
|
|||
describe('Test HLS videos', function () {
|
||||
let servers: ServerInfo[] = []
|
||||
let videoUUID = ''
|
||||
let videoAudioUUID = ''
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: true, hls: { enabled: true } } })
|
||||
const configOverride = {
|
||||
transcoding: {
|
||||
enabled: true,
|
||||
allow_audio_files: true,
|
||||
hls: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
servers = await flushAndRunMultipleServers(2, configOverride)
|
||||
|
||||
// Get the access tokens
|
||||
await setAccessTokensToServers(servers)
|
||||
|
@ -86,17 +94,28 @@ describe('Test HLS videos', function () {
|
|||
it('Should upload a video and transcode it to HLS', async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
{
|
||||
const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' })
|
||||
videoUUID = res.body.video.uuid
|
||||
}
|
||||
const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' })
|
||||
videoUUID = res.body.video.uuid
|
||||
|
||||
await waitJobs(servers)
|
||||
|
||||
await checkHlsPlaylist(servers, videoUUID)
|
||||
})
|
||||
|
||||
it('Should upload an audio file and transcode it to HLS', async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video audio', fixture: 'sample.ogg' })
|
||||
videoAudioUUID = res.body.video.uuid
|
||||
|
||||
await waitJobs(servers)
|
||||
|
||||
await checkHlsPlaylist(servers, videoAudioUUID, [ DEFAULT_AUDIO_RESOLUTION ])
|
||||
})
|
||||
|
||||
it('Should update the video', async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' })
|
||||
|
||||
await waitJobs(servers)
|
||||
|
@ -104,13 +123,17 @@ describe('Test HLS videos', function () {
|
|||
await checkHlsPlaylist(servers, videoUUID)
|
||||
})
|
||||
|
||||
it('Should delete the video', async function () {
|
||||
it('Should delete videos', async function () {
|
||||
this.timeout(10000)
|
||||
|
||||
await removeVideo(servers[0].url, servers[0].accessToken, videoUUID)
|
||||
await removeVideo(servers[0].url, servers[0].accessToken, videoAudioUUID)
|
||||
|
||||
await waitJobs(servers)
|
||||
|
||||
for (const server of servers) {
|
||||
await getVideo(server.url, videoUUID, 404)
|
||||
await getVideo(server.url, videoAudioUUID, 404)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
getMyVideos,
|
||||
getVideo,
|
||||
getVideosList,
|
||||
makeGetRequest,
|
||||
root,
|
||||
ServerInfo,
|
||||
setAccessTokensToServers,
|
||||
|
@ -365,6 +366,56 @@ describe('Test video transcoding', function () {
|
|||
expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false
|
||||
})
|
||||
|
||||
it('Should merge an audio file with the preview file', async function () {
|
||||
this.timeout(60000)
|
||||
|
||||
const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
|
||||
await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributesArg)
|
||||
|
||||
await waitJobs(servers)
|
||||
|
||||
for (const server of servers) {
|
||||
const res = await getVideosList(server.url)
|
||||
|
||||
const video = res.body.data.find(v => v.name === 'audio_with_preview')
|
||||
const res2 = await getVideo(server.url, video.id)
|
||||
const videoDetails: VideoDetails = res2.body
|
||||
|
||||
expect(videoDetails.files).to.have.lengthOf(1)
|
||||
|
||||
await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: 200 })
|
||||
await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: 200 })
|
||||
|
||||
const magnetUri = videoDetails.files[ 0 ].magnetUri
|
||||
expect(magnetUri).to.contain('.mp4')
|
||||
}
|
||||
})
|
||||
|
||||
it('Should upload an audio file and choose a default background image', async function () {
|
||||
this.timeout(60000)
|
||||
|
||||
const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' }
|
||||
await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributesArg)
|
||||
|
||||
await waitJobs(servers)
|
||||
|
||||
for (const server of servers) {
|
||||
const res = await getVideosList(server.url)
|
||||
|
||||
const video = res.body.data.find(v => v.name === 'audio_without_preview')
|
||||
const res2 = await getVideo(server.url, video.id)
|
||||
const videoDetails = res2.body
|
||||
|
||||
expect(videoDetails.files).to.have.lengthOf(1)
|
||||
|
||||
await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: 200 })
|
||||
await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: 200 })
|
||||
|
||||
const magnetUri = videoDetails.files[ 0 ].magnetUri
|
||||
expect(magnetUri).to.contain('.mp4')
|
||||
}
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests(servers)
|
||||
})
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 22 KiB |
|
@ -91,6 +91,7 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
|
|||
transcoding: {
|
||||
enabled: true,
|
||||
allowAdditionalExtensions: true,
|
||||
allowAudioFiles: true,
|
||||
threads: 1,
|
||||
resolutions: {
|
||||
'240p': false,
|
||||
|
|
|
@ -54,6 +54,7 @@ export interface CustomConfig {
|
|||
transcoding: {
|
||||
enabled: boolean
|
||||
allowAdditionalExtensions: boolean
|
||||
allowAudioFiles: boolean
|
||||
threads: number
|
||||
resolutions: {
|
||||
'240p': boolean
|
||||
|
|
Loading…
Reference in New Issue