mirror of https://github.com/Chocobozzz/PeerTube
Merge 7bfc652e9f
into 712f7d18e6
commit
49f53c2824
|
@ -0,0 +1,29 @@
|
|||
<div class="root">
|
||||
<div *ngIf="!selectingFromVideo" class="preview-container">
|
||||
|
||||
<img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview"
|
||||
alt="Preview" i18n-alt />
|
||||
<div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="embedContainer" class="" style="position: relative;"></div>
|
||||
|
||||
<div class="inputs">
|
||||
<my-reactive-file *ngIf="!selectingFromVideo" inputName="uploadNewThumbnail" inputLabel="Upload image"
|
||||
[extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right" icon="upload"
|
||||
(fileChanged)="onFileChanged($event)" [buttonTooltip]="getReactiveFileButtonTooltip()">
|
||||
</my-reactive-file>
|
||||
|
||||
<input type="button" *ngIf="!selectingFromVideo" i18n class="peertube-button grey-button"
|
||||
(click)="selectFromVideo()" value="Select from video" />
|
||||
|
||||
<input type="button" *ngIf="selectingFromVideo" i18n class="peertube-button orange-button" (click)="selectFrame()"
|
||||
value="Use frame" />
|
||||
|
||||
<input type="button" *ngIf="selectingFromVideo" i18n class="peertube-button grey-button"
|
||||
(click)="resetSelectFromVideo()" value="Cancel" />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,54 @@
|
|||
@use 'sass:math';
|
||||
@use '_variables' as *;
|
||||
|
||||
.root {
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
|
||||
.preview {
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
|
||||
&.no-image {
|
||||
border: 2px solid #808080;
|
||||
background-color: pvar(--mainBackgroundColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inputs {
|
||||
|
||||
margin-top: 10px;
|
||||
|
||||
my-reactive-file {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.video-embed {
|
||||
|
||||
$video-default-height: 40vh;
|
||||
|
||||
--player-height: #{$video-default-height};
|
||||
// Default player ratio, redefined by the player to automatically adapt player size
|
||||
--player-ratio: #{math.div(16, 9)};
|
||||
|
||||
width: 100%;
|
||||
height: var(--player-height);
|
||||
|
||||
// Can be recalculated by the player depending on video ratio
|
||||
max-width: calc(var(--player-height) * var(--player-ratio));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { imageToDataURL } from '@root-helpers/images'
|
||||
import { BytesPipe } from '@app/shared/shared-main/angular/bytes.pipe'
|
||||
|
||||
import {
|
||||
Component,
|
||||
forwardRef,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core'
|
||||
import {
|
||||
ServerService
|
||||
} from '@app/core'
|
||||
import { HTMLServerConfig } from '@peertube/peertube-models'
|
||||
import { ReactiveFileComponent } from '@app/shared/shared-forms/reactive-file.component'
|
||||
import { PeerTubePlayer } from 'src/standalone/embed-player-api/player'
|
||||
import { getAbsoluteAPIUrl } from '@app/helpers'
|
||||
|
||||
@Component({
|
||||
selector: 'my-thumbnail-manager',
|
||||
styleUrls: [ './thumbnail-manager.component.scss' ],
|
||||
templateUrl: './thumbnail-manager.component.html',
|
||||
standalone: true,
|
||||
imports: [ CommonModule, ReactiveFileComponent ],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => ThumbnailManagerComponent),
|
||||
multi: true
|
||||
}
|
||||
]
|
||||
})
|
||||
export class ThumbnailManagerComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
@Input() uuid: string
|
||||
|
||||
previewWidth = '360px'
|
||||
previewHeight = '200px'
|
||||
|
||||
imageSrc: string
|
||||
allowedExtensionsMessage = ''
|
||||
maxSizeText: string
|
||||
|
||||
serverConfig: HTMLServerConfig
|
||||
bytesPipe: BytesPipe
|
||||
imageFile: Blob
|
||||
|
||||
// State Toggle (Upload, Select Frame)
|
||||
selectingFromVideo = false
|
||||
|
||||
player: PeerTubePlayer
|
||||
|
||||
constructor (
|
||||
private serverService: ServerService
|
||||
) {
|
||||
this.bytesPipe = new BytesPipe()
|
||||
this.maxSizeText = $localize`max size`
|
||||
}
|
||||
|
||||
// Section - Upload
|
||||
get videoImageExtensions () {
|
||||
return this.serverConfig.video.image.extensions
|
||||
}
|
||||
|
||||
get maxVideoImageSize () {
|
||||
return this.serverConfig.video.image.size.max
|
||||
}
|
||||
|
||||
get maxVideoImageSizeInBytes () {
|
||||
return this.bytesPipe.transform(this.maxVideoImageSize)
|
||||
}
|
||||
|
||||
getReactiveFileButtonTooltip () {
|
||||
return $localize`(extensions: ${this.videoImageExtensions}, ${this.maxSizeText}\: ${this.maxVideoImageSizeInBytes})`
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getHTMLConfig()
|
||||
|
||||
this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
|
||||
}
|
||||
|
||||
onFileChanged (file: Blob) {
|
||||
this.imageFile = file
|
||||
|
||||
this.propagateChange(this.imageFile)
|
||||
this.updatePreview()
|
||||
}
|
||||
|
||||
propagateChange = (_: any) => { /* empty */ }
|
||||
|
||||
writeValue (file: any) {
|
||||
this.imageFile = file
|
||||
this.updatePreview()
|
||||
}
|
||||
|
||||
registerOnChange (fn: (_: any) => void) {
|
||||
this.propagateChange = fn
|
||||
}
|
||||
|
||||
registerOnTouched () {
|
||||
// Unused
|
||||
}
|
||||
|
||||
private updatePreview () {
|
||||
if (this.imageFile) {
|
||||
imageToDataURL(this.imageFile).then(result => this.imageSrc = result)
|
||||
}
|
||||
}
|
||||
// End Section - Upload
|
||||
|
||||
// Section - Select From Frame
|
||||
selectFromVideo () {
|
||||
|
||||
this.selectingFromVideo = true
|
||||
|
||||
const url = getAbsoluteAPIUrl()
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.src = `${url}/videos/embed/${this.uuid}?api=1&waitPasswordFromEmbedAPI=1&muted=1&title=0&peertubeLink=0`
|
||||
|
||||
iframe.sandbox.add('allow-same-origin', 'allow-scripts', 'allow-popups')
|
||||
|
||||
iframe.height = '100%'
|
||||
iframe.width = '100%'
|
||||
|
||||
const mainElement = document.querySelector('#embedContainer')
|
||||
mainElement.appendChild(iframe)
|
||||
|
||||
mainElement.classList.add('video-embed')
|
||||
|
||||
this.player = new PeerTubePlayer(iframe)
|
||||
}
|
||||
|
||||
resetSelectFromVideo () {
|
||||
|
||||
if (this.player) this.player.destroy()
|
||||
|
||||
const mainElement = document.querySelector('#embedContainer')
|
||||
|
||||
mainElement.classList.remove('video-embed')
|
||||
|
||||
this.selectingFromVideo = false
|
||||
}
|
||||
|
||||
async selectFrame () {
|
||||
|
||||
this.imageSrc = await this.player.getImageDataUrl()
|
||||
|
||||
this.propagateChange(this.imageSrc)
|
||||
|
||||
this.resetSelectFromVideo()
|
||||
|
||||
}
|
||||
// End Section - Upload
|
||||
}
|
|
@ -375,10 +375,7 @@
|
|||
<div class="form-group">
|
||||
<label i18n for="previewfile">Video thumbnail</label>
|
||||
|
||||
<my-preview-upload
|
||||
i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
|
||||
previewWidth="360px" previewHeight="200px"
|
||||
></my-preview-upload>
|
||||
<my-thumbnail-manager id="previewfile" formControlName="previewfile" [uuid]="videoToUpdate.uuid"></my-thumbnail-manager>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
|
@ -65,6 +65,7 @@ import { VideoService } from '@app/shared/shared-main/video/video.service'
|
|||
import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
|
||||
import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
|
||||
import { ThumbnailManagerComponent } from './thumbnail-manager/thumbnail-manager.component'
|
||||
|
||||
type VideoLanguages = VideoConstant<string> & { group?: string }
|
||||
type PluginField = {
|
||||
|
@ -108,7 +109,8 @@ type PluginField = {
|
|||
PreviewUploadComponent,
|
||||
NgbNavOutlet,
|
||||
VideoCaptionAddModalComponent,
|
||||
DatePipe
|
||||
DatePipe,
|
||||
ThumbnailManagerComponent
|
||||
]
|
||||
})
|
||||
export class VideoEditComponent implements OnInit, OnDestroy {
|
||||
|
|
|
@ -204,6 +204,13 @@ export class PeerTubePlayer {
|
|||
await this.sendMessage('setVideoPassword', password)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video frame image as data url
|
||||
*/
|
||||
async getImageDataUrl (): Promise<string> {
|
||||
return this.sendMessage('getImageDataUrl')
|
||||
}
|
||||
|
||||
private constructChannel () {
|
||||
this.channel = Channel.build({
|
||||
window: this.embedElement.contentWindow,
|
||||
|
|
|
@ -68,6 +68,8 @@ export class PeerTubeEmbedApi {
|
|||
channel.bind('playPreviousVideo', (txn, params) => this.embed.playPreviousPlaylistVideo())
|
||||
channel.bind('getCurrentPosition', (txn, params) => this.embed.getCurrentPlaylistPosition())
|
||||
|
||||
channel.bind('getImageDataUrl', (txn, params) => this.getImageDataUrl())
|
||||
|
||||
this.channel = channel
|
||||
}
|
||||
|
||||
|
@ -195,4 +197,18 @@ export class PeerTubeEmbedApi {
|
|||
private isWebVideo () {
|
||||
return !!this.embed.player.webVideo
|
||||
}
|
||||
|
||||
private getImageDataUrl (): string {
|
||||
|
||||
const video = this.element
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
|
||||
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
return canvas.toDataURL('image/jpeg')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ export interface VideoUpdate {
|
|||
waitTranscoding?: boolean
|
||||
channelId?: number
|
||||
thumbnailfile?: Blob
|
||||
previewfile?: Blob
|
||||
previewfile?: Blob | string
|
||||
scheduleUpdate?: VideoScheduleUpdate
|
||||
originallyPublishedAt?: Date | string
|
||||
videoPasswords?: string[]
|
||||
|
|
|
@ -10,7 +10,7 @@ import { setVideoTags } from '@server/lib/video.js'
|
|||
import { openapiOperationDoc } from '@server/middlewares/doc.js'
|
||||
import { VideoPasswordModel } from '@server/models/video/video-password.js'
|
||||
import { FilteredModelAttributes } from '@server/types/index.js'
|
||||
import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
|
||||
import { MThumbnail, MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
|
||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
|
||||
import { resetSequelizeInstance } from '../../../helpers/database-utils.js'
|
||||
import { createReqFiles } from '../../../helpers/express-utils.js'
|
||||
|
@ -25,6 +25,11 @@ import { VideoModel } from '../../../models/video/video.js'
|
|||
import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
|
||||
import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js'
|
||||
import { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
|
||||
import { startsWith } from 'lodash-es'
|
||||
import { promises } from 'fs'
|
||||
import { generateImageFilename } from '@server/helpers/image-utils.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { join } from 'path'
|
||||
|
||||
const lTags = loggerTagsFactory('api', 'video')
|
||||
const auditLogger = auditLoggerFactory('videos')
|
||||
|
@ -56,7 +61,18 @@ async function updateVideo (req: express.Request, res: express.Response) {
|
|||
const hadPrivacyForFederation = videoFromReq.hasPrivacyForFederation()
|
||||
const oldPrivacy = videoFromReq.privacy
|
||||
|
||||
const thumbnails = await buildVideoThumbnailsFromReq(videoFromReq, req.files)
|
||||
let thumbnails: MThumbnail[]
|
||||
|
||||
const previewfile = videoInfoToUpdate['previewfile']
|
||||
|
||||
// If the user has selected a thumbnail update from a video frame then the
|
||||
// previewfile field will be an image encoded as a base64 string.
|
||||
if (typeof previewfile === 'string' && startsWith(previewfile, 'data')) {
|
||||
thumbnails = await buildVideoThumnailsFromDataUrlImage(videoFromReq, previewfile)
|
||||
} else {
|
||||
thumbnails = await buildVideoThumbnailsFromReq(videoFromReq, req.files)
|
||||
}
|
||||
|
||||
const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoFromReq.uuid)
|
||||
|
||||
try {
|
||||
|
@ -252,3 +268,35 @@ async function buildVideoThumbnailsFromReq (video: MVideoThumbnail, files: Uploa
|
|||
|
||||
return thumbnailsOrUndefined.filter(t => !!t)
|
||||
}
|
||||
|
||||
async function buildVideoThumnailsFromDataUrlImage (video: MVideoThumbnail, dataUrlImage: string) {
|
||||
|
||||
const base64String: string = dataUrlImage.split(',')[1]
|
||||
|
||||
const buffer = Buffer.from(base64String, 'base64')
|
||||
|
||||
const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, generateImageFilename())
|
||||
const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR, generateImageFilename())
|
||||
|
||||
await promises.writeFile(thumbnailPath, buffer)
|
||||
await promises.writeFile(previewPath, buffer)
|
||||
|
||||
const thumbnail: MThumbnail =
|
||||
await updateLocalVideoMiniatureFromExisting({
|
||||
inputPath: thumbnailPath,
|
||||
video,
|
||||
type: ThumbnailType.MINIATURE,
|
||||
automaticallyGenerated: false
|
||||
})
|
||||
|
||||
const preview: MThumbnail =
|
||||
await updateLocalVideoMiniatureFromExisting({
|
||||
inputPath: previewPath,
|
||||
video,
|
||||
type: ThumbnailType.PREVIEW,
|
||||
automaticallyGenerated: false
|
||||
})
|
||||
|
||||
return [ thumbnail, preview ]
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue