From 7bfc652e9f89ba04b062fb85a890eb56356d48e7 Mon Sep 17 00:00:00 2001 From: Kent Anderson Date: Mon, 22 Apr 2024 20:44:24 -0500 Subject: [PATCH] feat-1322-v2-initial-commit --- .../thumbnail-manager.component.html | 29 ++++ .../thumbnail-manager.component.scss | 54 ++++++ .../thumbnail-manager.component.ts | 157 ++++++++++++++++++ .../shared/video-edit.component.html | 5 +- .../shared/video-edit.component.ts | 4 +- .../src/standalone/embed-player-api/player.ts | 7 + client/src/standalone/videos/embed-api.ts | 16 ++ .../models/src/videos/video-update.model.ts | 2 +- server/core/controllers/api/videos/update.ts | 52 +++++- 9 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html create mode 100644 client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.scss create mode 100644 client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts diff --git a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html new file mode 100644 index 000000000..92e6729f8 --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html @@ -0,0 +1,29 @@ +
+
+ + Preview +
+ +
+ +
+ +
+ + + + + + + + + +
+ +
\ No newline at end of file diff --git a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.scss b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.scss new file mode 100644 index 000000000..d9e795a4b --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.scss @@ -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)); + } +} \ No newline at end of file diff --git a/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts new file mode 100644 index 000000000..40f30430a --- /dev/null +++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts @@ -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 +} diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index 84c165236..50c61e475 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html @@ -375,10 +375,7 @@
- +
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 48b52e372..cc6e7b6e9 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts @@ -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 & { group?: string } type PluginField = { @@ -108,7 +109,8 @@ type PluginField = { PreviewUploadComponent, NgbNavOutlet, VideoCaptionAddModalComponent, - DatePipe + DatePipe, + ThumbnailManagerComponent ] }) export class VideoEditComponent implements OnInit, OnDestroy { diff --git a/client/src/standalone/embed-player-api/player.ts b/client/src/standalone/embed-player-api/player.ts index 803a7fdce..f73efe677 100644 --- a/client/src/standalone/embed-player-api/player.ts +++ b/client/src/standalone/embed-player-api/player.ts @@ -204,6 +204,13 @@ export class PeerTubePlayer { await this.sendMessage('setVideoPassword', password) } + /** + * Get video frame image as data url + */ + async getImageDataUrl (): Promise { + return this.sendMessage('getImageDataUrl') + } + private constructChannel () { this.channel = Channel.build({ window: this.embedElement.contentWindow, diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts index 0e8489b6d..713af6861 100644 --- a/client/src/standalone/videos/embed-api.ts +++ b/client/src/standalone/videos/embed-api.ts @@ -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') + } } diff --git a/packages/models/src/videos/video-update.model.ts b/packages/models/src/videos/video-update.model.ts index 8af298160..34c0b4c35 100644 --- a/packages/models/src/videos/video-update.model.ts +++ b/packages/models/src/videos/video-update.model.ts @@ -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[] diff --git a/server/core/controllers/api/videos/update.ts b/server/core/controllers/api/videos/update.ts index 8a191e59e..eaa330df7 100644 --- a/server/core/controllers/api/videos/update.ts +++ b/server/core/controllers/api/videos/update.ts @@ -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 ] + +}