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..56341290e
--- /dev/null
+++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.html
@@ -0,0 +1,41 @@
+
+
+
+
+ @if (!selectingFromVideo) {
+
+
+
+ @if (imageSrc) {
+
![Preview]()
+ }
+ @else {
+
+ }
+
+ }
+
+
+
+ @if (selectingFromVideo) {
+
+
+
+
+
+ }
+ @else {
+
+
+
+
+
+
+ }
+
+
+
+
\ 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..e91e9c703
--- /dev/null
+++ b/client/src/app/+videos/+video-edit/shared/thumbnail-manager/thumbnail-manager.component.ts
@@ -0,0 +1,198 @@
+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 () {
+
+ const dataUrl: string = await this.player.getImageDataUrl()
+
+ // Checking for an empty data URL
+ if (dataUrl.length <= 6) {
+ return
+ }
+
+ this.imageSrc = dataUrl
+
+ const blob: Blob = this.dataURItoBlob(dataUrl)
+
+ const file = new File([ blob ], 'PreviewFile.jpg', { type: 'image/jpeg' })
+
+ this.imageFile = file
+
+ this.propagateChange(this.imageFile)
+
+ this.resetSelectFromVideo()
+
+ }
+
+ /*
+ * Credit: https://stackoverflow.com/a/7261048/1030669
+ */
+ dataURItoBlob (dataURI: string) {
+ // convert base64 to raw binary data held in a string
+ // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
+ const byteString = atob(dataURI.split(',')[1])
+
+ // separate out the mime component
+ const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
+
+ // write the bytes of the string to an ArrayBuffer
+ const ab = new ArrayBuffer(byteString.length)
+
+ // create a view into the buffer
+ const ia = new Uint8Array(ab)
+
+ // set the bytes of the buffer to the correct values
+ for (let i = 0; i < byteString.length; i++) {
+ ia[i] = byteString.charCodeAt(i)
+ }
+
+ // write the ArrayBuffer to a blob, and you're done
+ const blob = new Blob([ ab ], { type: mimeString })
+ return blob
+
+ }
+ // 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 a1a49ff7d..8e93cc923 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 09d3c2b0f..118dc6b51 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
@@ -66,6 +66,7 @@ import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
import { VideoEditType } from './video-edit.type'
+import { ThumbnailManagerComponent } from './thumbnail-manager/thumbnail-manager.component'
type VideoLanguages = VideoConstant & { group?: string }
type PluginField = {
@@ -109,7 +110,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 879d251a6..cc7b178db 100644
--- a/client/src/standalone/videos/embed-api.ts
+++ b/client/src/standalone/videos/embed-api.ts
@@ -67,6 +67,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.embed.getImageDataUrl())
+
this.channel = channel
}
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 5114ec72b..d3407c7e6 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -432,6 +432,20 @@ export class PeerTubeEmbed {
this.player = this.peertubePlayer.getPlayer()
}
+
+ getImageDataUrl (): string {
+
+ const video = this.playerHTML.getInitVideoEl()
+
+ 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')
+ }
}
PeerTubeEmbed.main()