Compare commits

...

5 commits

Author SHA1 Message Date
Kent Anderson 49f53c2824
Merge 7bfc652e9f into 712f7d18e6 2024-04-25 11:32:22 +02:00
Chocobozzz 712f7d18e6
Update superagent
To fix vulnerability of formidable
2024-04-25 11:01:10 +02:00
Chocobozzz 47ae6e880d
Fix AP actor follows count 2024-04-25 10:53:53 +02:00
Chocobozzz 9244620f37
Fix view explanation 2024-04-25 09:33:05 +02:00
Kent Anderson 7bfc652e9f feat-1322-v2-initial-commit 2024-04-22 20:44:24 -05:00
13 changed files with 335 additions and 26 deletions

View file

@ -309,7 +309,7 @@ export class VideoStatsComponent implements OnInit {
{
label: $localize`Views`,
value: this.numberFormatter.transform(this.video.views),
help: $localize`A view means that someone watched the video for at least 30 seconds`
help: $localize`A view means that someone watched the video for several seconds (10 seconds by default)`
},
{
label: $localize`Likes`,

View file

@ -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>

View file

@ -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));
}
}

View file

@ -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
}

View file

@ -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">

View file

@ -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 {

View file

@ -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,

View file

@ -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')
}
}

View file

@ -236,7 +236,7 @@
"pngjs": "^7.0.0",
"proxy": "^2.1.1",
"socket.io-client": "^4.5.4",
"supertest": "^6.0.1",
"supertest": "^7.0.0",
"swagger-cli": "^4.0.2",
"tsc-watch": "^6.0.0",
"tsx": "^4.7.1",

View file

@ -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[]

View file

@ -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 ]
}

View file

@ -710,7 +710,7 @@ export class ActorFollowModel extends SequelizeModel<ActorFollowModel> {
data: followers.map(f => ({ selectionUrl: f.selectionUrl, createdAt: f.createdAt })) as { selectionUrl: string, createdAt: string }[],
total: selectTotal
? parseInt(resDataTotal?.dataTotal?.[0]?.total || 0, 10)
? parseInt(resDataTotal?.[0]?.total || 0, 10)
: undefined
}
}

View file

@ -5701,15 +5701,14 @@ formdata-polyfill@^4.0.10:
dependencies:
fetch-blob "^3.1.2"
formidable@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89"
integrity sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==
formidable@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.1.tgz#9360a23a656f261207868b1484624c4c8d06ee1a"
integrity sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==
dependencies:
dezalgo "^1.0.4"
hexoid "^1.0.0"
once "^1.4.0"
qs "^6.11.0"
forwarded@0.2.0:
version "0.2.0"
@ -9787,29 +9786,29 @@ subarg@^1.0.0:
dependencies:
minimist "^1.1.0"
superagent@^8.1.2:
version "8.1.2"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b"
integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==
superagent@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-9.0.1.tgz#660773036c03728a1a88649a5d7e15d89b1d6961"
integrity sha512-CcRSdb/P2oUVaEpQ87w9Obsl+E9FruRd6b2b7LdiBtJoyMr2DQt7a89anAfiX/EL59j9b2CbRFvf2S91DhuCww==
dependencies:
component-emitter "^1.3.0"
cookiejar "^2.1.4"
debug "^4.3.4"
fast-safe-stringify "^2.1.1"
form-data "^4.0.0"
formidable "^2.1.2"
formidable "^3.5.1"
methods "^1.1.2"
mime "2.6.0"
qs "^6.11.0"
semver "^7.3.8"
supertest@^6.0.1:
version "6.3.4"
resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.3.4.tgz#2145c250570c2ea5d337db3552dbfb78a2286218"
integrity sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==
supertest@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.0.0.tgz#cac53b3d6872a0b317980b2b0cfa820f09cd7634"
integrity sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==
dependencies:
methods "^1.1.2"
superagent "^8.1.2"
superagent "^9.0.1"
supports-color@8.1.1, supports-color@^8.1.1:
version "8.1.1"