Add storyboard support
This commit is contained in:
parent
1fb7d09422
commit
d8f39b126d
|
@ -52,6 +52,20 @@
|
||||||
|
|
||||||
<div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div>
|
<div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" formGroupName="torrents">
|
||||||
|
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
|
||||||
|
|
||||||
|
<div class="number-with-unit">
|
||||||
|
<input
|
||||||
|
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
|
||||||
|
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
|
||||||
|
>
|
||||||
|
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="formErrors.cache.storyboards.size" class="form-error">{{ formErrors.cache.storyboards.size }}</div>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,7 +10,7 @@ export class EditAdvancedConfigurationComponent {
|
||||||
@Input() form: FormGroup
|
@Input() form: FormGroup
|
||||||
@Input() formErrors: any
|
@Input() formErrors: any
|
||||||
|
|
||||||
getCacheSize (type: 'captions' | 'previews' | 'torrents') {
|
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
|
||||||
return this.form.value['cache'][type]['size']
|
return this.form.value['cache'][type]['size']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,7 @@ import { Notifier } from '@app/core'
|
||||||
import { ServerService } from '@app/core/server/server.service'
|
import { ServerService } from '@app/core/server/server.service'
|
||||||
import {
|
import {
|
||||||
ADMIN_EMAIL_VALIDATOR,
|
ADMIN_EMAIL_VALIDATOR,
|
||||||
CACHE_CAPTIONS_SIZE_VALIDATOR,
|
CACHE_SIZE_VALIDATOR,
|
||||||
CACHE_PREVIEWS_SIZE_VALIDATOR,
|
|
||||||
CONCURRENCY_VALIDATOR,
|
CONCURRENCY_VALIDATOR,
|
||||||
INDEX_URL_VALIDATOR,
|
INDEX_URL_VALIDATOR,
|
||||||
INSTANCE_NAME_VALIDATOR,
|
INSTANCE_NAME_VALIDATOR,
|
||||||
|
@ -120,13 +119,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
previews: {
|
previews: {
|
||||||
size: CACHE_PREVIEWS_SIZE_VALIDATOR
|
size: CACHE_SIZE_VALIDATOR
|
||||||
},
|
},
|
||||||
captions: {
|
captions: {
|
||||||
size: CACHE_CAPTIONS_SIZE_VALIDATOR
|
size: CACHE_SIZE_VALIDATOR
|
||||||
},
|
},
|
||||||
torrents: {
|
torrents: {
|
||||||
size: CACHE_CAPTIONS_SIZE_VALIDATOR
|
size: CACHE_SIZE_VALIDATOR
|
||||||
|
},
|
||||||
|
storyboards: {
|
||||||
|
size: CACHE_SIZE_VALIDATOR
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
|
|
|
@ -33,6 +33,7 @@ import {
|
||||||
LiveVideo,
|
LiveVideo,
|
||||||
PeerTubeProblemDocument,
|
PeerTubeProblemDocument,
|
||||||
ServerErrorCode,
|
ServerErrorCode,
|
||||||
|
Storyboard,
|
||||||
VideoCaption,
|
VideoCaption,
|
||||||
VideoPrivacy,
|
VideoPrivacy,
|
||||||
VideoState
|
VideoState
|
||||||
|
@ -69,6 +70,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoCaptions: VideoCaption[] = []
|
videoCaptions: VideoCaption[] = []
|
||||||
liveVideo: LiveVideo
|
liveVideo: LiveVideo
|
||||||
videoPassword: string
|
videoPassword: string
|
||||||
|
storyboards: Storyboard[] = []
|
||||||
|
|
||||||
playlistPosition: number
|
playlistPosition: number
|
||||||
playlist: VideoPlaylist = null
|
playlist: VideoPlaylist = null
|
||||||
|
@ -285,9 +287,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
forkJoin([
|
forkJoin([
|
||||||
videoAndLiveObs,
|
videoAndLiveObs,
|
||||||
this.videoCaptionService.listCaptions(videoId, videoPassword),
|
this.videoCaptionService.listCaptions(videoId, videoPassword),
|
||||||
|
this.videoService.getStoryboards(videoId),
|
||||||
this.userService.getAnonymousOrLoggedUser()
|
this.userService.getAnonymousOrLoggedUser()
|
||||||
]).subscribe({
|
]).subscribe({
|
||||||
next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => {
|
next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
|
||||||
const queryParams = this.route.snapshot.queryParams
|
const queryParams = this.route.snapshot.queryParams
|
||||||
|
|
||||||
const urlOptions = {
|
const urlOptions = {
|
||||||
|
@ -309,6 +312,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video,
|
video,
|
||||||
live,
|
live,
|
||||||
videoCaptions: captionsResult.data,
|
videoCaptions: captionsResult.data,
|
||||||
|
storyboards,
|
||||||
videoFileToken,
|
videoFileToken,
|
||||||
videoPassword,
|
videoPassword,
|
||||||
loggedInOrAnonymousUser,
|
loggedInOrAnonymousUser,
|
||||||
|
@ -414,6 +418,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video: VideoDetails
|
video: VideoDetails
|
||||||
live: LiveVideo
|
live: LiveVideo
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
|
storyboards: Storyboard[]
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
videoPassword: string
|
videoPassword: string
|
||||||
|
|
||||||
|
@ -421,7 +426,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
loggedInOrAnonymousUser: User
|
loggedInOrAnonymousUser: User
|
||||||
forceAutoplay: boolean
|
forceAutoplay: boolean
|
||||||
}) {
|
}) {
|
||||||
const { video, live, videoCaptions, urlOptions, videoFileToken, videoPassword, loggedInOrAnonymousUser, forceAutoplay } = options
|
const {
|
||||||
|
video,
|
||||||
|
live,
|
||||||
|
videoCaptions,
|
||||||
|
storyboards,
|
||||||
|
urlOptions,
|
||||||
|
videoFileToken,
|
||||||
|
videoPassword,
|
||||||
|
loggedInOrAnonymousUser,
|
||||||
|
forceAutoplay
|
||||||
|
} = options
|
||||||
|
|
||||||
this.subscribeToLiveEventsIfNeeded(this.video, video)
|
this.subscribeToLiveEventsIfNeeded(this.video, video)
|
||||||
|
|
||||||
|
@ -430,6 +445,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
this.liveVideo = live
|
this.liveVideo = live
|
||||||
this.videoFileToken = videoFileToken
|
this.videoFileToken = videoFileToken
|
||||||
this.videoPassword = videoPassword
|
this.videoPassword = videoPassword
|
||||||
|
this.storyboards = storyboards
|
||||||
|
|
||||||
// Re init attributes
|
// Re init attributes
|
||||||
this.playerPlaceholderImgSrc = undefined
|
this.playerPlaceholderImgSrc = undefined
|
||||||
|
@ -485,6 +501,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
const params = {
|
const params = {
|
||||||
video: this.video,
|
video: this.video,
|
||||||
videoCaptions: this.videoCaptions,
|
videoCaptions: this.videoCaptions,
|
||||||
|
storyboards: this.storyboards,
|
||||||
liveVideo: this.liveVideo,
|
liveVideo: this.liveVideo,
|
||||||
videoFileToken: this.videoFileToken,
|
videoFileToken: this.videoFileToken,
|
||||||
videoPassword: this.videoPassword,
|
videoPassword: this.videoPassword,
|
||||||
|
@ -636,6 +653,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
video: VideoDetails
|
video: VideoDetails
|
||||||
liveVideo: LiveVideo
|
liveVideo: LiveVideo
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
|
storyboards: Storyboard[]
|
||||||
|
|
||||||
videoFileToken: string
|
videoFileToken: string
|
||||||
videoPassword: string
|
videoPassword: string
|
||||||
|
@ -646,7 +664,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
forceAutoplay: boolean
|
forceAutoplay: boolean
|
||||||
user?: AuthUser // Keep for plugins
|
user?: AuthUser // Keep for plugins
|
||||||
}) {
|
}) {
|
||||||
const { video, liveVideo, videoCaptions, videoFileToken, videoPassword, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params
|
const {
|
||||||
|
video,
|
||||||
|
liveVideo,
|
||||||
|
videoCaptions,
|
||||||
|
storyboards,
|
||||||
|
videoFileToken,
|
||||||
|
videoPassword,
|
||||||
|
urlOptions,
|
||||||
|
loggedInOrAnonymousUser,
|
||||||
|
forceAutoplay
|
||||||
|
} = params
|
||||||
|
|
||||||
const getStartTime = () => {
|
const getStartTime = () => {
|
||||||
const byUrl = urlOptions.startTime !== undefined
|
const byUrl = urlOptions.startTime !== undefined
|
||||||
|
@ -673,6 +701,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
src: environment.apiUrl + c.captionPath
|
src: environment.apiUrl + c.captionPath
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const storyboard = storyboards.length !== 0
|
||||||
|
? {
|
||||||
|
url: environment.apiUrl + storyboards[0].storyboardPath,
|
||||||
|
height: storyboards[0].spriteHeight,
|
||||||
|
width: storyboards[0].spriteWidth,
|
||||||
|
interval: storyboards[0].spriteDuration
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const liveOptions = video.isLive
|
const liveOptions = video.isLive
|
||||||
? { latencyMode: liveVideo.latencyMode }
|
? { latencyMode: liveVideo.latencyMode }
|
||||||
: undefined
|
: undefined
|
||||||
|
@ -734,6 +771,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
videoPassword: () => videoPassword,
|
videoPassword: () => videoPassword,
|
||||||
|
|
||||||
videoCaptions: playerCaptions,
|
videoCaptions: playerCaptions,
|
||||||
|
storyboard,
|
||||||
|
|
||||||
videoShortUUID: video.shortUUID,
|
videoShortUUID: video.shortUUID,
|
||||||
videoUUID: video.uuid,
|
videoUUID: video.uuid,
|
||||||
|
@ -767,6 +805,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
||||||
else mode = 'webtorrent'
|
else mode = 'webtorrent'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: remove, we don't support these old web browsers anymore
|
||||||
// p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
|
// p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available
|
||||||
if (typeof TextEncoder === 'undefined') {
|
if (typeof TextEncoder === 'undefined') {
|
||||||
mode = 'webtorrent'
|
mode = 'webtorrent'
|
||||||
|
|
|
@ -22,21 +22,12 @@ export const SERVICES_TWITTER_USERNAME_VALIDATOR: BuildFormValidator = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CACHE_PREVIEWS_SIZE_VALIDATOR: BuildFormValidator = {
|
export const CACHE_SIZE_VALIDATOR: BuildFormValidator = {
|
||||||
VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
|
VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
|
||||||
MESSAGES: {
|
MESSAGES: {
|
||||||
required: $localize`Previews cache size is required.`,
|
required: $localize`Cache size is required.`,
|
||||||
min: $localize`Previews cache size must be greater than 1.`,
|
min: $localize`Cache size must be greater than 1.`,
|
||||||
pattern: $localize`Previews cache size must be a number.`
|
pattern: $localize`Cache size must be a number.`
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CACHE_CAPTIONS_SIZE_VALIDATOR: BuildFormValidator = {
|
|
||||||
VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
|
|
||||||
MESSAGES: {
|
|
||||||
required: $localize`Captions cache size is required.`,
|
|
||||||
min: $localize`Captions cache size must be greater than 1.`,
|
|
||||||
pattern: $localize`Captions cache size must be a number.`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
FeedFormat,
|
FeedFormat,
|
||||||
NSFWPolicyType,
|
NSFWPolicyType,
|
||||||
ResultList,
|
ResultList,
|
||||||
|
Storyboard,
|
||||||
UserVideoRate,
|
UserVideoRate,
|
||||||
UserVideoRateType,
|
UserVideoRateType,
|
||||||
UserVideoRateUpdate,
|
UserVideoRateUpdate,
|
||||||
|
@ -344,6 +345,25 @@ export class VideoService {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getStoryboards (videoId: string | number) {
|
||||||
|
return this.authHttp
|
||||||
|
.get<{ storyboards: Storyboard[] }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/storyboards')
|
||||||
|
.pipe(
|
||||||
|
map(({ storyboards }) => storyboards),
|
||||||
|
catchError(err => {
|
||||||
|
if (err.status === 404) {
|
||||||
|
return of([])
|
||||||
|
}
|
||||||
|
|
||||||
|
this.restExtractor.handleError(err)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
getSource (videoId: number) {
|
getSource (videoId: number) {
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
|
.get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
|
||||||
|
@ -358,6 +378,8 @@ export class VideoService {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
setVideoLike (id: string, videoPassword: string) {
|
setVideoLike (id: string, videoPassword: string) {
|
||||||
return this.setVideoRate(id, 'like', videoPassword)
|
return this.setVideoRate(id, 'like', videoPassword)
|
||||||
}
|
}
|
||||||
|
@ -370,6 +392,8 @@ export class VideoService {
|
||||||
return this.setVideoRate(id, 'none', videoPassword)
|
return this.setVideoRate(id, 'none', videoPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
getUserVideoRating (id: string) {
|
getUserVideoRating (id: string) {
|
||||||
const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
|
const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import './shared/stats/stats-plugin'
|
||||||
import './shared/bezels/bezels-plugin'
|
import './shared/bezels/bezels-plugin'
|
||||||
import './shared/peertube/peertube-plugin'
|
import './shared/peertube/peertube-plugin'
|
||||||
import './shared/resolutions/peertube-resolutions-plugin'
|
import './shared/resolutions/peertube-resolutions-plugin'
|
||||||
|
import './shared/control-bar/storyboard-plugin'
|
||||||
import './shared/control-bar/next-previous-video-button'
|
import './shared/control-bar/next-previous-video-button'
|
||||||
import './shared/control-bar/p2p-info-button'
|
import './shared/control-bar/p2p-info-button'
|
||||||
import './shared/control-bar/peertube-link-button'
|
import './shared/control-bar/peertube-link-button'
|
||||||
|
@ -42,6 +43,12 @@ CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
|
||||||
// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
|
// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
|
||||||
CaptionsButton.prototype.label_ = ' '
|
CaptionsButton.prototype.label_ = ' '
|
||||||
|
|
||||||
|
// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged
|
||||||
|
const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any
|
||||||
|
if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) {
|
||||||
|
PlayProgressBar.prototype.options_.children.push('timeTooltip')
|
||||||
|
}
|
||||||
|
|
||||||
export class PeertubePlayerManager {
|
export class PeertubePlayerManager {
|
||||||
private static playerElementClassName: string
|
private static playerElementClassName: string
|
||||||
private static playerElementAttributes: { name: string, value: string }[] = []
|
private static playerElementAttributes: { name: string, value: string }[] = []
|
||||||
|
@ -135,6 +142,10 @@ export class PeertubePlayerManager {
|
||||||
p2pEnabled: options.common.p2pEnabled
|
p2pEnabled: options.common.p2pEnabled
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (options.common.storyboard) {
|
||||||
|
player.storyboard(options.common.storyboard)
|
||||||
|
}
|
||||||
|
|
||||||
player.on('p2pInfo', (_, data: PlayerNetworkInfo) => {
|
player.on('p2pInfo', (_, data: PlayerNetworkInfo) => {
|
||||||
if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
|
if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
|
||||||
|
|
||||||
|
|
|
@ -3,4 +3,5 @@ export * from './p2p-info-button'
|
||||||
export * from './peertube-link-button'
|
export * from './peertube-link-button'
|
||||||
export * from './peertube-live-display'
|
export * from './peertube-live-display'
|
||||||
export * from './peertube-load-progress-bar'
|
export * from './peertube-load-progress-bar'
|
||||||
|
export * from './storyboard-plugin'
|
||||||
export * from './theater-button'
|
export * from './theater-button'
|
||||||
|
|
|
@ -0,0 +1,184 @@
|
||||||
|
import videojs from 'video.js'
|
||||||
|
import { StoryboardOptions } from '../../types'
|
||||||
|
|
||||||
|
// Big thanks to this beautiful plugin: https://github.com/phloxic/videojs-sprite-thumbnails
|
||||||
|
// Adapted to respect peertube player style
|
||||||
|
|
||||||
|
const Plugin = videojs.getPlugin('plugin')
|
||||||
|
|
||||||
|
class StoryboardPlugin extends Plugin {
|
||||||
|
private url: string
|
||||||
|
private height: number
|
||||||
|
private width: number
|
||||||
|
private interval: number
|
||||||
|
|
||||||
|
private cached: boolean
|
||||||
|
|
||||||
|
private mouseTimeTooltip: videojs.MouseTimeDisplay
|
||||||
|
private seekBar: { el(): HTMLElement, mouseTimeDisplay: any, playProgressBar: any }
|
||||||
|
private progress: any
|
||||||
|
|
||||||
|
private spritePlaceholder: HTMLElement
|
||||||
|
|
||||||
|
private readonly sprites: { [id: string]: HTMLImageElement } = {}
|
||||||
|
|
||||||
|
private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip
|
||||||
|
|
||||||
|
constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) {
|
||||||
|
super(player, options)
|
||||||
|
|
||||||
|
this.url = options.url
|
||||||
|
this.height = options.height
|
||||||
|
this.width = options.width
|
||||||
|
this.interval = options.interval
|
||||||
|
|
||||||
|
this.boundedHijackMouseTooltip = this.hijackMouseTooltip.bind(this)
|
||||||
|
|
||||||
|
this.player.ready(() => {
|
||||||
|
player.addClass('vjs-storyboard')
|
||||||
|
|
||||||
|
this.init()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init () {
|
||||||
|
const controls = this.player.controlBar as any
|
||||||
|
|
||||||
|
// default control bar component tree is expected
|
||||||
|
// https://docs.videojs.com/tutorial-components.html#default-component-tree
|
||||||
|
this.progress = controls?.progressControl
|
||||||
|
this.seekBar = this.progress?.seekBar
|
||||||
|
|
||||||
|
this.mouseTimeTooltip = this.seekBar?.mouseTimeDisplay?.timeTooltip
|
||||||
|
|
||||||
|
this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement
|
||||||
|
this.seekBar?.el()?.appendChild(this.spritePlaceholder)
|
||||||
|
|
||||||
|
this.player.on([ 'ready', 'loadstart' ], evt => {
|
||||||
|
if (evt !== 'ready') {
|
||||||
|
const spriteSource = this.player.currentSources().find(source => {
|
||||||
|
return Object.prototype.hasOwnProperty.call(source, 'storyboard')
|
||||||
|
}) as any
|
||||||
|
const spriteOpts = spriteSource?.['storyboard'] as StoryboardOptions
|
||||||
|
|
||||||
|
if (spriteOpts) {
|
||||||
|
this.url = spriteOpts.url
|
||||||
|
this.height = spriteOpts.height
|
||||||
|
this.width = spriteOpts.width
|
||||||
|
this.interval = spriteOpts.interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cached = !!this.sprites[this.url]
|
||||||
|
|
||||||
|
this.load()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private load () {
|
||||||
|
const spriteEvents = [ 'mousemove', 'touchmove' ]
|
||||||
|
|
||||||
|
if (this.isReady()) {
|
||||||
|
if (!this.cached) {
|
||||||
|
this.sprites[this.url] = videojs.dom.createEl('img', {
|
||||||
|
src: this.url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.progress.on(spriteEvents, this.boundedHijackMouseTooltip)
|
||||||
|
} else {
|
||||||
|
this.progress.off(spriteEvents, this.boundedHijackMouseTooltip)
|
||||||
|
|
||||||
|
this.resetMouseTooltip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private hijackMouseTooltip (evt: Event) {
|
||||||
|
const sprite = this.sprites[this.url]
|
||||||
|
const imgWidth = sprite.naturalWidth
|
||||||
|
const imgHeight = sprite.naturalHeight
|
||||||
|
const seekBarEl = this.seekBar.el()
|
||||||
|
|
||||||
|
if (!sprite.complete || !imgWidth || !imgHeight) {
|
||||||
|
this.resetMouseTooltip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.player.requestNamedAnimationFrame('StoryBoardPlugin#hijackMouseTooltip', () => {
|
||||||
|
const seekBarRect = videojs.dom.getBoundingClientRect(seekBarEl)
|
||||||
|
const playerRect = videojs.dom.getBoundingClientRect(this.player.el())
|
||||||
|
|
||||||
|
if (!seekBarRect || !playerRect) return
|
||||||
|
|
||||||
|
const seekBarX = videojs.dom.getPointerPosition(seekBarEl, evt).x
|
||||||
|
let position = seekBarX * this.player.duration()
|
||||||
|
|
||||||
|
const maxPosition = Math.round((imgHeight / this.height) * (imgWidth / this.width)) - 1
|
||||||
|
position = Math.min(position / this.interval, maxPosition)
|
||||||
|
|
||||||
|
const responsive = 600
|
||||||
|
const playerWidth = this.player.currentWidth()
|
||||||
|
const scaleFactor = responsive && playerWidth < responsive
|
||||||
|
? playerWidth / responsive
|
||||||
|
: 1
|
||||||
|
const columns = imgWidth / this.width
|
||||||
|
|
||||||
|
const scaledWidth = this.width * scaleFactor
|
||||||
|
const scaledHeight = this.height * scaleFactor
|
||||||
|
const cleft = Math.floor(position % columns) * -scaledWidth
|
||||||
|
const ctop = Math.floor(position / columns) * -scaledHeight
|
||||||
|
|
||||||
|
const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
|
||||||
|
const topOffset = -scaledHeight - 60
|
||||||
|
|
||||||
|
const previewHalfSize = Math.round(scaledWidth / 2)
|
||||||
|
let left = seekBarRect.width * seekBarX - previewHalfSize
|
||||||
|
|
||||||
|
// Seek bar doesn't take all the player width, so we can add/minus a few more pixels
|
||||||
|
const minLeft = playerRect.left - seekBarRect.left
|
||||||
|
const maxLeft = seekBarRect.width - scaledWidth + (playerRect.right - seekBarRect.right)
|
||||||
|
|
||||||
|
if (left < minLeft) left = minLeft
|
||||||
|
if (left > maxLeft) left = maxLeft
|
||||||
|
|
||||||
|
const tooltipStyle: { [id: string]: string } = {
|
||||||
|
'background-image': `url("${this.url}")`,
|
||||||
|
'background-repeat': 'no-repeat',
|
||||||
|
'background-position': `${cleft}px ${ctop}px`,
|
||||||
|
'background-size': bgSize,
|
||||||
|
|
||||||
|
'color': '#fff',
|
||||||
|
'text-shadow': '1px 1px #000',
|
||||||
|
|
||||||
|
'position': 'relative',
|
||||||
|
|
||||||
|
'top': `${topOffset}px`,
|
||||||
|
|
||||||
|
'border': '1px solid #000',
|
||||||
|
|
||||||
|
// border should not overlay thumbnail area
|
||||||
|
'width': `${scaledWidth + 2}px`,
|
||||||
|
'height': `${scaledHeight + 2}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipStyle.left = `${left}px`
|
||||||
|
|
||||||
|
for (const [ key, value ] of Object.entries(tooltipStyle)) {
|
||||||
|
this.spritePlaceholder.style.setProperty(key, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetMouseTooltip () {
|
||||||
|
if (this.spritePlaceholder) {
|
||||||
|
this.spritePlaceholder.style.cssText = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReady () {
|
||||||
|
return this.mouseTimeTooltip && this.width && this.height && this.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
videojs.registerPlugin('storyboard', StoryboardPlugin)
|
||||||
|
|
||||||
|
export { StoryboardPlugin }
|
|
@ -1,6 +1,6 @@
|
||||||
import { PluginsManager } from '@root-helpers/plugins-manager'
|
import { PluginsManager } from '@root-helpers/plugins-manager'
|
||||||
import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
|
import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
|
||||||
import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings'
|
import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
|
||||||
|
|
||||||
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
|
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
|
||||||
|
|
||||||
|
@ -78,6 +78,7 @@ export interface CommonOptions extends CustomizationOptions {
|
||||||
language?: string
|
language?: string
|
||||||
|
|
||||||
videoCaptions: VideoJSCaption[]
|
videoCaptions: VideoJSCaption[]
|
||||||
|
storyboard: VideoJSStoryboard
|
||||||
|
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
videoShortUUID: string
|
videoShortUUID: string
|
||||||
|
|
|
@ -49,6 +49,8 @@ declare module 'video.js' {
|
||||||
|
|
||||||
stats (options?: StatsCardOptions): StatsForNerdsPlugin
|
stats (options?: StatsCardOptions): StatsForNerdsPlugin
|
||||||
|
|
||||||
|
storyboard (options: StoryboardOptions): void
|
||||||
|
|
||||||
textTracks (): TextTrackList & {
|
textTracks (): TextTrackList & {
|
||||||
tracks_: (TextTrack & { id: string, label: string, src: string })[]
|
tracks_: (TextTrack & { id: string, label: string, src: string })[]
|
||||||
}
|
}
|
||||||
|
@ -89,6 +91,13 @@ type VideoJSCaption = {
|
||||||
src: string
|
src: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VideoJSStoryboard = {
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
type PeerTubePluginOptions = {
|
type PeerTubePluginOptions = {
|
||||||
mode: PlayerMode
|
mode: PlayerMode
|
||||||
|
|
||||||
|
@ -118,6 +127,13 @@ type MetricsPluginOptions = {
|
||||||
videoUUID: string
|
videoUUID: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoryboardOptions = {
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
type PlaylistPluginOptions = {
|
type PlaylistPluginOptions = {
|
||||||
elements: VideoPlaylistElement[]
|
elements: VideoPlaylistElement[]
|
||||||
|
|
||||||
|
@ -238,6 +254,7 @@ type PlaylistItemOptions = {
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PlayerNetworkInfo,
|
PlayerNetworkInfo,
|
||||||
|
VideoJSStoryboard,
|
||||||
PlaylistItemOptions,
|
PlaylistItemOptions,
|
||||||
NextPreviousVideoButtonOptions,
|
NextPreviousVideoButtonOptions,
|
||||||
ResolutionUpdateData,
|
ResolutionUpdateData,
|
||||||
|
@ -251,6 +268,7 @@ export {
|
||||||
PeerTubeResolution,
|
PeerTubeResolution,
|
||||||
VideoJSPluginOptions,
|
VideoJSPluginOptions,
|
||||||
LoadedQualityData,
|
LoadedQualityData,
|
||||||
|
StoryboardOptions,
|
||||||
PeerTubeLinkButtonOptions,
|
PeerTubeLinkButtonOptions,
|
||||||
PeerTubeP2PInfoButtonOptions
|
PeerTubeP2PInfoButtonOptions
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,20 @@
|
||||||
@use '_mixins' as *;
|
@use '_mixins' as *;
|
||||||
@use './_player-variables' as *;
|
@use './_player-variables' as *;
|
||||||
|
|
||||||
|
// Like the time tooltip
|
||||||
|
.video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder,
|
||||||
|
.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
// Ensure that we maintain a font-size of ~10px.
|
||||||
|
font-size: 0.6em;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.video-js.vjs-peertube-skin .vjs-control-bar {
|
.video-js.vjs-peertube-skin .vjs-control-bar {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
|
@ -79,6 +93,7 @@
|
||||||
top: -0.3em;
|
top: -0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only used on mobile
|
||||||
.vjs-time-tooltip {
|
.vjs-time-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,31 @@
|
||||||
/* Special mobile style */
|
/* Special mobile style */
|
||||||
|
|
||||||
.video-js.vjs-peertube-skin.vjs-is-mobile {
|
.video-js.vjs-peertube-skin.vjs-is-mobile {
|
||||||
|
// No hover means we can't display the storyboard/time tooltip on mouse hover
|
||||||
|
// Use the time tooltip in progress control instead
|
||||||
|
.vjs-mouse-display {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vjs-storyboard-sprite-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vjs-progress-control .vjs-sliding {
|
||||||
|
|
||||||
|
.vjs-time-tooltip,
|
||||||
|
.vjs-storyboard-sprite-placeholder {
|
||||||
|
display: block !important;
|
||||||
|
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vjs-time-tooltip {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.vjs-control-bar {
|
.vjs-control-bar {
|
||||||
.vjs-progress-control .vjs-slider .vjs-play-progress {
|
.vjs-progress-control .vjs-slider .vjs-play-progress {
|
||||||
// Always display the circle on mobile
|
// Always display the circle on mobile
|
||||||
|
|
|
@ -136,6 +136,7 @@ storage:
|
||||||
logs: 'storage/logs/'
|
logs: 'storage/logs/'
|
||||||
previews: 'storage/previews/'
|
previews: 'storage/previews/'
|
||||||
thumbnails: 'storage/thumbnails/'
|
thumbnails: 'storage/thumbnails/'
|
||||||
|
storyboards: 'storage/storyboards/'
|
||||||
torrents: 'storage/torrents/'
|
torrents: 'storage/torrents/'
|
||||||
captions: 'storage/captions/'
|
captions: 'storage/captions/'
|
||||||
cache: 'storage/cache/'
|
cache: 'storage/cache/'
|
||||||
|
@ -396,6 +397,8 @@ cache:
|
||||||
size: 500 # Max number of video captions/subtitles you want to cache
|
size: 500 # Max number of video captions/subtitles you want to cache
|
||||||
torrents:
|
torrents:
|
||||||
size: 500 # Max number of video torrents you want to cache
|
size: 500 # Max number of video torrents you want to cache
|
||||||
|
storyboards:
|
||||||
|
size: 500 # Max number of video storyboards you want to cache
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
# Used to generate the root user at first startup
|
# Used to generate the root user at first startup
|
||||||
|
|
|
@ -134,6 +134,7 @@ storage:
|
||||||
logs: '/var/www/peertube/storage/logs/'
|
logs: '/var/www/peertube/storage/logs/'
|
||||||
previews: '/var/www/peertube/storage/previews/'
|
previews: '/var/www/peertube/storage/previews/'
|
||||||
thumbnails: '/var/www/peertube/storage/thumbnails/'
|
thumbnails: '/var/www/peertube/storage/thumbnails/'
|
||||||
|
storyboards: '/var/www/peertube/storage/storyboards/'
|
||||||
torrents: '/var/www/peertube/storage/torrents/'
|
torrents: '/var/www/peertube/storage/torrents/'
|
||||||
captions: '/var/www/peertube/storage/captions/'
|
captions: '/var/www/peertube/storage/captions/'
|
||||||
cache: '/var/www/peertube/storage/cache/'
|
cache: '/var/www/peertube/storage/cache/'
|
||||||
|
@ -406,6 +407,8 @@ cache:
|
||||||
size: 500 # Max number of video captions/subtitles you want to cache
|
size: 500 # Max number of video captions/subtitles you want to cache
|
||||||
torrents:
|
torrents:
|
||||||
size: 500 # Max number of video torrents you want to cache
|
size: 500 # Max number of video torrents you want to cache
|
||||||
|
storyboards:
|
||||||
|
size: 500 # Max number of video storyboards you want to cache
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
# Used to generate the root user at first startup
|
# Used to generate the root user at first startup
|
||||||
|
|
|
@ -19,6 +19,7 @@ storage:
|
||||||
logs: 'test1/logs/'
|
logs: 'test1/logs/'
|
||||||
previews: 'test1/previews/'
|
previews: 'test1/previews/'
|
||||||
thumbnails: 'test1/thumbnails/'
|
thumbnails: 'test1/thumbnails/'
|
||||||
|
storyboards: 'test1/storyboards/'
|
||||||
torrents: 'test1/torrents/'
|
torrents: 'test1/torrents/'
|
||||||
captions: 'test1/captions/'
|
captions: 'test1/captions/'
|
||||||
cache: 'test1/cache/'
|
cache: 'test1/cache/'
|
||||||
|
|
|
@ -19,6 +19,7 @@ storage:
|
||||||
logs: 'test2/logs/'
|
logs: 'test2/logs/'
|
||||||
previews: 'test2/previews/'
|
previews: 'test2/previews/'
|
||||||
thumbnails: 'test2/thumbnails/'
|
thumbnails: 'test2/thumbnails/'
|
||||||
|
storyboards: 'test2/storyboards/'
|
||||||
torrents: 'test2/torrents/'
|
torrents: 'test2/torrents/'
|
||||||
captions: 'test2/captions/'
|
captions: 'test2/captions/'
|
||||||
cache: 'test2/cache/'
|
cache: 'test2/cache/'
|
||||||
|
|
|
@ -19,6 +19,7 @@ storage:
|
||||||
logs: 'test3/logs/'
|
logs: 'test3/logs/'
|
||||||
previews: 'test3/previews/'
|
previews: 'test3/previews/'
|
||||||
thumbnails: 'test3/thumbnails/'
|
thumbnails: 'test3/thumbnails/'
|
||||||
|
storyboards: 'test3/storyboards/'
|
||||||
torrents: 'test3/torrents/'
|
torrents: 'test3/torrents/'
|
||||||
captions: 'test3/captions/'
|
captions: 'test3/captions/'
|
||||||
cache: 'test3/cache/'
|
cache: 'test3/cache/'
|
||||||
|
|
|
@ -19,6 +19,7 @@ storage:
|
||||||
logs: 'test4/logs/'
|
logs: 'test4/logs/'
|
||||||
previews: 'test4/previews/'
|
previews: 'test4/previews/'
|
||||||
thumbnails: 'test4/thumbnails/'
|
thumbnails: 'test4/thumbnails/'
|
||||||
|
storyboards: 'test4/storyboards/'
|
||||||
torrents: 'test4/torrents/'
|
torrents: 'test4/torrents/'
|
||||||
captions: 'test4/captions/'
|
captions: 'test4/captions/'
|
||||||
cache: 'test4/cache/'
|
cache: 'test4/cache/'
|
||||||
|
|
|
@ -19,6 +19,7 @@ storage:
|
||||||
logs: 'test5/logs/'
|
logs: 'test5/logs/'
|
||||||
previews: 'test5/previews/'
|
previews: 'test5/previews/'
|
||||||
thumbnails: 'test5/thumbnails/'
|
thumbnails: 'test5/thumbnails/'
|
||||||
|
storyboards: 'test5/storyboards/'
|
||||||
torrents: 'test5/torrents/'
|
torrents: 'test5/torrents/'
|
||||||
captions: 'test5/captions/'
|
captions: 'test5/captions/'
|
||||||
cache: 'test5/cache/'
|
cache: 'test5/cache/'
|
||||||
|
|
|
@ -19,6 +19,7 @@ storage:
|
||||||
logs: 'test6/logs/'
|
logs: 'test6/logs/'
|
||||||
previews: 'test6/previews/'
|
previews: 'test6/previews/'
|
||||||
thumbnails: 'test6/thumbnails/'
|
thumbnails: 'test6/thumbnails/'
|
||||||
|
storyboards: 'test6/storyboards/'
|
||||||
torrents: 'test6/torrents/'
|
torrents: 'test6/torrents/'
|
||||||
captions: 'test6/captions/'
|
captions: 'test6/captions/'
|
||||||
cache: 'test6/cache/'
|
cache: 'test6/cache/'
|
||||||
|
|
|
@ -73,6 +73,8 @@ cache:
|
||||||
size: 1
|
size: 1
|
||||||
torrents:
|
torrents:
|
||||||
size: 1
|
size: 1
|
||||||
|
storyboards:
|
||||||
|
size: 1
|
||||||
|
|
||||||
signup:
|
signup:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
|
@ -101,7 +101,7 @@ loadLanguages()
|
||||||
import { installApplication } from './server/initializers/installer'
|
import { installApplication } from './server/initializers/installer'
|
||||||
import { Emailer } from './server/lib/emailer'
|
import { Emailer } from './server/lib/emailer'
|
||||||
import { JobQueue } from './server/lib/job-queue'
|
import { JobQueue } from './server/lib/job-queue'
|
||||||
import { VideosPreviewCache, VideosCaptionCache } from './server/lib/files-cache'
|
import { VideosPreviewCache, VideosCaptionCache, VideosStoryboardCache } from './server/lib/files-cache'
|
||||||
import {
|
import {
|
||||||
activityPubRouter,
|
activityPubRouter,
|
||||||
apiRouter,
|
apiRouter,
|
||||||
|
@ -316,6 +316,7 @@ async function startApplication () {
|
||||||
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE)
|
VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE)
|
||||||
VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE)
|
VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE)
|
||||||
VideosTorrentCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE)
|
VideosTorrentCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE)
|
||||||
|
VideosStoryboardCache.Instance.init(CONFIG.CACHE.STORYBOARDS.SIZE, FILES_CACHE.STORYBOARDS.MAX_AGE)
|
||||||
|
|
||||||
// Enable Schedulers
|
// Enable Schedulers
|
||||||
ActorFollowScheduler.Instance.enable()
|
ActorFollowScheduler.Instance.enable()
|
||||||
|
|
|
@ -33,7 +33,6 @@ import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '
|
||||||
import { AccountModel } from '../../models/account/account'
|
import { AccountModel } from '../../models/account/account'
|
||||||
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
|
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
|
||||||
import { ActorFollowModel } from '../../models/actor/actor-follow'
|
import { ActorFollowModel } from '../../models/actor/actor-follow'
|
||||||
import { VideoCaptionModel } from '../../models/video/video-caption'
|
|
||||||
import { VideoCommentModel } from '../../models/video/video-comment'
|
import { VideoCommentModel } from '../../models/video/video-comment'
|
||||||
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
import { VideoPlaylistModel } from '../../models/video/video-playlist'
|
||||||
import { VideoShareModel } from '../../models/video/video-share'
|
import { VideoShareModel } from '../../models/video/video-share'
|
||||||
|
@ -242,14 +241,13 @@ async function videoController (req: express.Request, res: express.Response) {
|
||||||
if (redirectIfNotOwned(video.url, res)) return
|
if (redirectIfNotOwned(video.url, res)) return
|
||||||
|
|
||||||
// We need captions to render AP object
|
// We need captions to render AP object
|
||||||
const captions = await VideoCaptionModel.listVideoCaptions(video.id)
|
const videoAP = await video.lightAPToFullAP(undefined)
|
||||||
const videoWithCaptions = Object.assign(video, { VideoCaptions: captions })
|
|
||||||
|
|
||||||
const audience = getAudience(videoWithCaptions.VideoChannel.Account.Actor, videoWithCaptions.privacy === VideoPrivacy.PUBLIC)
|
const audience = getAudience(videoAP.VideoChannel.Account.Actor, videoAP.privacy === VideoPrivacy.PUBLIC)
|
||||||
const videoObject = audiencify(await videoWithCaptions.toActivityPubObject(), audience)
|
const videoObject = audiencify(await videoAP.toActivityPubObject(), audience)
|
||||||
|
|
||||||
if (req.path.endsWith('/activity')) {
|
if (req.path.endsWith('/activity')) {
|
||||||
const data = buildCreateActivity(videoWithCaptions.url, video.VideoChannel.Account.Actor, videoObject, audience)
|
const data = buildCreateActivity(videoAP.url, video.VideoChannel.Account.Actor, videoObject, audience)
|
||||||
return activityPubResponse(activityPubContextify(data, 'Video'), res)
|
return activityPubResponse(activityPubContextify(data, 'Video'), res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -190,6 +190,9 @@ function customConfig (): CustomConfig {
|
||||||
},
|
},
|
||||||
torrents: {
|
torrents: {
|
||||||
size: CONFIG.CACHE.TORRENTS.SIZE
|
size: CONFIG.CACHE.TORRENTS.SIZE
|
||||||
|
},
|
||||||
|
storyboards: {
|
||||||
|
size: CONFIG.CACHE.STORYBOARDS.SIZE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { liveRouter } from './live'
|
||||||
import { ownershipVideoRouter } from './ownership'
|
import { ownershipVideoRouter } from './ownership'
|
||||||
import { rateVideoRouter } from './rate'
|
import { rateVideoRouter } from './rate'
|
||||||
import { statsRouter } from './stats'
|
import { statsRouter } from './stats'
|
||||||
|
import { storyboardRouter } from './storyboard'
|
||||||
import { studioRouter } from './studio'
|
import { studioRouter } from './studio'
|
||||||
import { tokenRouter } from './token'
|
import { tokenRouter } from './token'
|
||||||
import { transcodingRouter } from './transcoding'
|
import { transcodingRouter } from './transcoding'
|
||||||
|
@ -70,6 +71,7 @@ videosRouter.use('/', filesRouter)
|
||||||
videosRouter.use('/', transcodingRouter)
|
videosRouter.use('/', transcodingRouter)
|
||||||
videosRouter.use('/', tokenRouter)
|
videosRouter.use('/', tokenRouter)
|
||||||
videosRouter.use('/', videoPasswordRouter)
|
videosRouter.use('/', videoPasswordRouter)
|
||||||
|
videosRouter.use('/', storyboardRouter)
|
||||||
|
|
||||||
videosRouter.get('/categories',
|
videosRouter.get('/categories',
|
||||||
openapiOperationDoc({ operationId: 'getCategories' }),
|
openapiOperationDoc({ operationId: 'getCategories' }),
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import express from 'express'
|
||||||
|
import { getVideoWithAttributes } from '@server/helpers/video'
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard'
|
||||||
|
import { asyncMiddleware, videosGetValidator } from '../../../middlewares'
|
||||||
|
|
||||||
|
const storyboardRouter = express.Router()
|
||||||
|
|
||||||
|
storyboardRouter.get('/:id/storyboards',
|
||||||
|
asyncMiddleware(videosGetValidator),
|
||||||
|
asyncMiddleware(listStoryboards)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
storyboardRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function listStoryboards (req: express.Request, res: express.Response) {
|
||||||
|
const video = getVideoWithAttributes(res)
|
||||||
|
|
||||||
|
const storyboards = await StoryboardModel.listStoryboardsOf(video)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
storyboards: storyboards.map(s => s.toFormattedJSON())
|
||||||
|
})
|
||||||
|
}
|
|
@ -234,6 +234,15 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
// No need to federate, we process these jobs sequentially
|
||||||
|
federate: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
type: 'notify',
|
type: 'notify',
|
||||||
payload: {
|
payload: {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { MActorImage } from '@server/types/models'
|
||||||
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
|
||||||
import { logger } from '../helpers/logger'
|
import { logger } from '../helpers/logger'
|
||||||
import { ACTOR_IMAGES_SIZE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants'
|
import { ACTOR_IMAGES_SIZE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants'
|
||||||
import { VideosCaptionCache, VideosPreviewCache } from '../lib/files-cache'
|
import { VideosCaptionCache, VideosPreviewCache, VideosStoryboardCache } from '../lib/files-cache'
|
||||||
import { actorImagePathUnsafeCache, downloadActorImageFromWorker } from '../lib/local-actor'
|
import { actorImagePathUnsafeCache, downloadActorImageFromWorker } from '../lib/local-actor'
|
||||||
import { asyncMiddleware, handleStaticError } from '../middlewares'
|
import { asyncMiddleware, handleStaticError } from '../middlewares'
|
||||||
import { ActorImageModel } from '../models/actor/actor-image'
|
import { ActorImageModel } from '../models/actor/actor-image'
|
||||||
|
@ -32,6 +32,12 @@ lazyStaticRouter.use(
|
||||||
handleStaticError
|
handleStaticError
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lazyStaticRouter.use(
|
||||||
|
LAZY_STATIC_PATHS.STORYBOARDS + ':filename',
|
||||||
|
asyncMiddleware(getStoryboard),
|
||||||
|
handleStaticError
|
||||||
|
)
|
||||||
|
|
||||||
lazyStaticRouter.use(
|
lazyStaticRouter.use(
|
||||||
LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename',
|
LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename',
|
||||||
asyncMiddleware(getVideoCaption),
|
asyncMiddleware(getVideoCaption),
|
||||||
|
@ -126,6 +132,13 @@ async function getPreview (req: express.Request, res: express.Response) {
|
||||||
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
|
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStoryboard (req: express.Request, res: express.Response) {
|
||||||
|
const result = await VideosStoryboardCache.Instance.getFilePath(req.params.filename)
|
||||||
|
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
||||||
|
|
||||||
|
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
|
||||||
|
}
|
||||||
|
|
||||||
async function getVideoCaption (req: express.Request, res: express.Response) {
|
async function getVideoCaption (req: express.Request, res: express.Response) {
|
||||||
const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename)
|
const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename)
|
||||||
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@shared/models'
|
import { ActivityPubStoryboard, ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject, VideoObject } from '@shared/models'
|
||||||
import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos'
|
import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos'
|
||||||
import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants'
|
import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants'
|
||||||
import { peertubeTruncate } from '../../core-utils'
|
import { peertubeTruncate } from '../../core-utils'
|
||||||
|
@ -48,6 +48,10 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
|
||||||
logger.debug('Video has invalid icons', { video })
|
logger.debug('Video has invalid icons', { video })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (!setValidStoryboard(video)) {
|
||||||
|
logger.debug('Video has invalid preview (storyboard)', { video })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Default attributes
|
// Default attributes
|
||||||
if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
|
if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
|
||||||
|
@ -201,3 +205,36 @@ function setRemoteVideoContent (video: any) {
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setValidStoryboard (video: VideoObject) {
|
||||||
|
if (!video.preview) return true
|
||||||
|
if (!Array.isArray(video.preview)) return false
|
||||||
|
|
||||||
|
video.preview = video.preview.filter(p => isStorybordValid(p))
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStorybordValid (preview: ActivityPubStoryboard) {
|
||||||
|
if (!preview) return false
|
||||||
|
|
||||||
|
if (
|
||||||
|
preview.type !== 'Image' ||
|
||||||
|
!isArray(preview.rel) ||
|
||||||
|
!preview.rel.includes('storyboard')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.url = preview.url.filter(u => {
|
||||||
|
return u.mediaType === 'image/jpeg' &&
|
||||||
|
isActivityPubUrlValid(u.href) &&
|
||||||
|
validator.isInt(u.width + '', { min: 0 }) &&
|
||||||
|
validator.isInt(u.height + '', { min: 0 }) &&
|
||||||
|
validator.isInt(u.tileWidth + '', { min: 0 }) &&
|
||||||
|
validator.isInt(u.tileHeight + '', { min: 0 }) &&
|
||||||
|
isActivityPubVideoDurationValid(u.tileDuration)
|
||||||
|
})
|
||||||
|
|
||||||
|
return preview.url.length !== 0
|
||||||
|
}
|
||||||
|
|
|
@ -29,7 +29,8 @@ function checkMissedConfig () {
|
||||||
'video_channels.max_per_user',
|
'video_channels.max_per_user',
|
||||||
'csp.enabled', 'csp.report_only', 'csp.report_uri',
|
'csp.enabled', 'csp.report_only', 'csp.report_uri',
|
||||||
'security.frameguard.enabled', 'security.powered_by_header.enabled',
|
'security.frameguard.enabled', 'security.powered_by_header.enabled',
|
||||||
'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled',
|
'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'cache.storyboards.size',
|
||||||
|
'admin.email', 'contact_form.enabled',
|
||||||
'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
|
'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
|
||||||
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
|
'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
|
||||||
'redundancy.videos.strategies', 'redundancy.videos.check_interval',
|
'redundancy.videos.strategies', 'redundancy.videos.check_interval',
|
||||||
|
|
|
@ -112,6 +112,7 @@ const CONFIG = {
|
||||||
STREAMING_PLAYLISTS_DIR: buildPath(config.get<string>('storage.streaming_playlists')),
|
STREAMING_PLAYLISTS_DIR: buildPath(config.get<string>('storage.streaming_playlists')),
|
||||||
REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
|
REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
|
||||||
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
|
THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
|
||||||
|
STORYBOARDS_DIR: buildPath(config.get<string>('storage.storyboards')),
|
||||||
PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
|
PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
|
||||||
CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
|
CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
|
||||||
TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
|
TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
|
||||||
|
@ -482,6 +483,9 @@ const CONFIG = {
|
||||||
},
|
},
|
||||||
TORRENTS: {
|
TORRENTS: {
|
||||||
get SIZE () { return config.get<number>('cache.torrents.size') }
|
get SIZE () { return config.get<number>('cache.torrents.size') }
|
||||||
|
},
|
||||||
|
STORYBOARDS: {
|
||||||
|
get SIZE () { return config.get<number>('cache.storyboards.size') }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
INSTANCE: {
|
INSTANCE: {
|
||||||
|
|
|
@ -174,6 +174,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
|
||||||
'after-video-channel-import': 1,
|
'after-video-channel-import': 1,
|
||||||
'move-to-object-storage': 3,
|
'move-to-object-storage': 3,
|
||||||
'transcoding-job-builder': 1,
|
'transcoding-job-builder': 1,
|
||||||
|
'generate-video-storyboard': 1,
|
||||||
'notify': 1,
|
'notify': 1,
|
||||||
'federate-video': 1
|
'federate-video': 1
|
||||||
}
|
}
|
||||||
|
@ -198,6 +199,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
|
||||||
'video-channel-import': 1,
|
'video-channel-import': 1,
|
||||||
'after-video-channel-import': 1,
|
'after-video-channel-import': 1,
|
||||||
'transcoding-job-builder': 1,
|
'transcoding-job-builder': 1,
|
||||||
|
'generate-video-storyboard': 1,
|
||||||
'notify': 5,
|
'notify': 5,
|
||||||
'federate-video': 3
|
'federate-video': 3
|
||||||
}
|
}
|
||||||
|
@ -218,6 +220,7 @@ const JOB_TTL: { [id in JobType]: number } = {
|
||||||
'activitypub-refresher': 60000 * 10, // 10 minutes
|
'activitypub-refresher': 60000 * 10, // 10 minutes
|
||||||
'video-redundancy': 1000 * 3600 * 3, // 3 hours
|
'video-redundancy': 1000 * 3600 * 3, // 3 hours
|
||||||
'video-live-ending': 1000 * 60 * 10, // 10 minutes
|
'video-live-ending': 1000 * 60 * 10, // 10 minutes
|
||||||
|
'generate-video-storyboard': 1000 * 60 * 10, // 10 minutes
|
||||||
'manage-video-torrent': 1000 * 3600 * 3, // 3 hours
|
'manage-video-torrent': 1000 * 3600 * 3, // 3 hours
|
||||||
'move-to-object-storage': 1000 * 60 * 60 * 3, // 3 hours
|
'move-to-object-storage': 1000 * 60 * 60 * 3, // 3 hours
|
||||||
'video-channel-import': 1000 * 60 * 60 * 4, // 4 hours
|
'video-channel-import': 1000 * 60 * 60 * 4, // 4 hours
|
||||||
|
@ -766,7 +769,8 @@ const LAZY_STATIC_PATHS = {
|
||||||
AVATARS: '/lazy-static/avatars/',
|
AVATARS: '/lazy-static/avatars/',
|
||||||
PREVIEWS: '/lazy-static/previews/',
|
PREVIEWS: '/lazy-static/previews/',
|
||||||
VIDEO_CAPTIONS: '/lazy-static/video-captions/',
|
VIDEO_CAPTIONS: '/lazy-static/video-captions/',
|
||||||
TORRENTS: '/lazy-static/torrents/'
|
TORRENTS: '/lazy-static/torrents/',
|
||||||
|
STORYBOARDS: '/lazy-static/storyboards/'
|
||||||
}
|
}
|
||||||
const OBJECT_STORAGE_PROXY_PATHS = {
|
const OBJECT_STORAGE_PROXY_PATHS = {
|
||||||
PRIVATE_WEBSEED: '/object-storage-proxy/webseed/private/',
|
PRIVATE_WEBSEED: '/object-storage-proxy/webseed/private/',
|
||||||
|
@ -813,6 +817,14 @@ const ACTOR_IMAGES_SIZE: { [key in ActorImageType]: { width: number, height: num
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORYBOARD = {
|
||||||
|
SPRITE_SIZE: {
|
||||||
|
width: 192,
|
||||||
|
height: 108
|
||||||
|
},
|
||||||
|
SPRITES_MAX_EDGE_COUNT: 10
|
||||||
|
}
|
||||||
|
|
||||||
const EMBED_SIZE = {
|
const EMBED_SIZE = {
|
||||||
width: 560,
|
width: 560,
|
||||||
height: 315
|
height: 315
|
||||||
|
@ -824,6 +836,10 @@ const FILES_CACHE = {
|
||||||
DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
|
DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
|
||||||
MAX_AGE: 1000 * 3600 * 3 // 3 hours
|
MAX_AGE: 1000 * 3600 * 3 // 3 hours
|
||||||
},
|
},
|
||||||
|
STORYBOARDS: {
|
||||||
|
DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'storyboards'),
|
||||||
|
MAX_AGE: 1000 * 3600 * 24 // 24 hours
|
||||||
|
},
|
||||||
VIDEO_CAPTIONS: {
|
VIDEO_CAPTIONS: {
|
||||||
DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions'),
|
DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions'),
|
||||||
MAX_AGE: 1000 * 3600 * 3 // 3 hours
|
MAX_AGE: 1000 * 3600 * 3 // 3 hours
|
||||||
|
@ -1090,6 +1106,7 @@ export {
|
||||||
RESUMABLE_UPLOAD_SESSION_LIFETIME,
|
RESUMABLE_UPLOAD_SESSION_LIFETIME,
|
||||||
RUNNER_JOB_STATES,
|
RUNNER_JOB_STATES,
|
||||||
P2P_MEDIA_LOADER_PEER_VERSION,
|
P2P_MEDIA_LOADER_PEER_VERSION,
|
||||||
|
STORYBOARD,
|
||||||
ACTOR_IMAGES_SIZE,
|
ACTOR_IMAGES_SIZE,
|
||||||
ACCEPT_HEADERS,
|
ACCEPT_HEADERS,
|
||||||
BCRYPT_SALT_SIZE,
|
BCRYPT_SALT_SIZE,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { UserModel } from '@server/models/user/user'
|
||||||
import { UserNotificationModel } from '@server/models/user/user-notification'
|
import { UserNotificationModel } from '@server/models/user/user-notification'
|
||||||
import { UserRegistrationModel } from '@server/models/user/user-registration'
|
import { UserRegistrationModel } from '@server/models/user/user-registration'
|
||||||
import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
|
import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard'
|
||||||
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
|
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
|
||||||
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
|
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
|
||||||
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
|
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
|
||||||
|
@ -167,7 +168,8 @@ async function initDatabaseModels (silent: boolean) {
|
||||||
VideoPasswordModel,
|
VideoPasswordModel,
|
||||||
RunnerRegistrationTokenModel,
|
RunnerRegistrationTokenModel,
|
||||||
RunnerModel,
|
RunnerModel,
|
||||||
RunnerJobModel
|
RunnerJobModel,
|
||||||
|
StoryboardModel
|
||||||
])
|
])
|
||||||
|
|
||||||
// Check extensions exist in the database
|
// Check extensions exist in the database
|
||||||
|
|
|
@ -46,6 +46,19 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
|
||||||
|
|
||||||
Infohash: 'pt:Infohash',
|
Infohash: 'pt:Infohash',
|
||||||
|
|
||||||
|
tileWidth: {
|
||||||
|
'@type': 'sc:Number',
|
||||||
|
'@id': 'pt:tileWidth'
|
||||||
|
},
|
||||||
|
tileHeight: {
|
||||||
|
'@type': 'sc:Number',
|
||||||
|
'@id': 'pt:tileHeight'
|
||||||
|
},
|
||||||
|
tileDuration: {
|
||||||
|
'@type': 'sc:Number',
|
||||||
|
'@id': 'pt:tileDuration'
|
||||||
|
},
|
||||||
|
|
||||||
originallyPublishedAt: 'sc:datePublished',
|
originallyPublishedAt: 'sc:datePublished',
|
||||||
views: {
|
views: {
|
||||||
'@type': 'sc:Number',
|
'@type': 'sc:Number',
|
||||||
|
|
|
@ -10,8 +10,7 @@ import {
|
||||||
MActor,
|
MActor,
|
||||||
MActorLight,
|
MActorLight,
|
||||||
MChannelDefault,
|
MChannelDefault,
|
||||||
MVideoAP,
|
MVideoAPLight,
|
||||||
MVideoAPWithoutCaption,
|
|
||||||
MVideoPlaylistFull,
|
MVideoPlaylistFull,
|
||||||
MVideoRedundancyVideo
|
MVideoRedundancyVideo
|
||||||
} from '../../../types/models'
|
} from '../../../types/models'
|
||||||
|
@ -20,10 +19,10 @@ import { getUpdateActivityPubUrl } from '../url'
|
||||||
import { getActorsInvolvedInVideo } from './shared'
|
import { getActorsInvolvedInVideo } from './shared'
|
||||||
import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils'
|
import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils'
|
||||||
|
|
||||||
async function sendUpdateVideo (videoArg: MVideoAPWithoutCaption, transaction: Transaction, overriddenByActor?: MActor) {
|
async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
|
||||||
const video = videoArg as MVideoAP
|
if (!videoArg.hasPrivacyForFederation()) return undefined
|
||||||
|
|
||||||
if (!video.hasPrivacyForFederation()) return undefined
|
const video = await videoArg.lightAPToFullAP(transaction)
|
||||||
|
|
||||||
logger.info('Creating job to update video %s.', video.url)
|
logger.info('Creating job to update video %s.', video.url)
|
||||||
|
|
||||||
|
@ -31,11 +30,6 @@ async function sendUpdateVideo (videoArg: MVideoAPWithoutCaption, transaction: T
|
||||||
|
|
||||||
const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
|
const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
|
||||||
|
|
||||||
// Needed to build the AP object
|
|
||||||
if (!video.VideoCaptions) {
|
|
||||||
video.VideoCaptions = await video.$get('VideoCaptions', { transaction })
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoObject = await video.toActivityPubObject()
|
const videoObject = await video.toActivityPubObject()
|
||||||
const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
|
const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { Transaction } from 'sequelize/types'
|
import { Transaction } from 'sequelize/types'
|
||||||
import { isArray } from '@server/helpers/custom-validators/misc'
|
import { MVideoAP, MVideoAPLight } from '@server/types/models'
|
||||||
import { MVideoAP, MVideoAPWithoutCaption } from '@server/types/models'
|
|
||||||
import { sendCreateVideo, sendUpdateVideo } from '../send'
|
import { sendCreateVideo, sendUpdateVideo } from '../send'
|
||||||
import { shareVideoByServerAndChannel } from '../share'
|
import { shareVideoByServerAndChannel } from '../share'
|
||||||
|
|
||||||
async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: Transaction) {
|
async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
|
||||||
const video = videoArg as MVideoAP
|
const video = videoArg as MVideoAP
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -13,13 +12,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
|
||||||
// Check the video is public/unlisted and published
|
// Check the video is public/unlisted and published
|
||||||
video.hasPrivacyForFederation() && video.hasStateForFederation()
|
video.hasPrivacyForFederation() && video.hasStateForFederation()
|
||||||
) {
|
) {
|
||||||
// Fetch more attributes that we will need to serialize in AP object
|
const video = await videoArg.lightAPToFullAP(transaction)
|
||||||
if (isArray(video.VideoCaptions) === false) {
|
|
||||||
video.VideoCaptions = await video.$get('VideoCaptions', {
|
|
||||||
attributes: [ 'filename', 'language' ],
|
|
||||||
transaction
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNewVideo) {
|
if (isNewVideo) {
|
||||||
// Now we'll add the video's meta data to our followers
|
// Now we'll add the video's meta data to our followers
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { deleteAllModels, filterNonExistingModels } from '@server/helpers/databa
|
||||||
import { logger, LoggerTagsFn } from '@server/helpers/logger'
|
import { logger, LoggerTagsFn } from '@server/helpers/logger'
|
||||||
import { updatePlaceholderThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail'
|
import { updatePlaceholderThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail'
|
||||||
import { setVideoTags } from '@server/lib/video'
|
import { setVideoTags } from '@server/lib/video'
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard'
|
||||||
import { VideoCaptionModel } from '@server/models/video/video-caption'
|
import { VideoCaptionModel } from '@server/models/video/video-caption'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file'
|
import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
import { VideoLiveModel } from '@server/models/video/video-live'
|
import { VideoLiveModel } from '@server/models/video/video-live'
|
||||||
|
@ -24,6 +25,7 @@ import {
|
||||||
getFileAttributesFromUrl,
|
getFileAttributesFromUrl,
|
||||||
getLiveAttributesFromObject,
|
getLiveAttributesFromObject,
|
||||||
getPreviewFromIcons,
|
getPreviewFromIcons,
|
||||||
|
getStoryboardAttributeFromObject,
|
||||||
getStreamingPlaylistAttributesFromObject,
|
getStreamingPlaylistAttributesFromObject,
|
||||||
getTagsFromObject,
|
getTagsFromObject,
|
||||||
getThumbnailFromIcons
|
getThumbnailFromIcons
|
||||||
|
@ -107,6 +109,16 @@ export abstract class APVideoAbstractBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) {
|
||||||
|
const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t)
|
||||||
|
if (existingStoryboard) await existingStoryboard.destroy({ transaction: t })
|
||||||
|
|
||||||
|
const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
|
||||||
|
if (!storyboardAttributes) return
|
||||||
|
|
||||||
|
return StoryboardModel.create(storyboardAttributes, { transaction: t })
|
||||||
|
}
|
||||||
|
|
||||||
protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
|
protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
|
||||||
const attributes = getLiveAttributesFromObject(video, this.videoObject)
|
const attributes = getLiveAttributesFromObject(video, this.videoObject)
|
||||||
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
|
const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
|
||||||
|
|
|
@ -48,6 +48,7 @@ export class APVideoCreator extends APVideoAbstractBuilder {
|
||||||
await this.setTrackers(videoCreated, t)
|
await this.setTrackers(videoCreated, t)
|
||||||
await this.insertOrReplaceCaptions(videoCreated, t)
|
await this.insertOrReplaceCaptions(videoCreated, t)
|
||||||
await this.insertOrReplaceLive(videoCreated, t)
|
await this.insertOrReplaceLive(videoCreated, t)
|
||||||
|
await this.insertOrReplaceStoryboard(videoCreated, t)
|
||||||
|
|
||||||
// We added a video in this channel, set it as updated
|
// We added a video in this channel, set it as updated
|
||||||
await channel.setAsUpdated(t)
|
await channel.setAsUpdated(t)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { maxBy, minBy } from 'lodash'
|
import { maxBy, minBy } from 'lodash'
|
||||||
import { decode as magnetUriDecode } from 'magnet-uri'
|
import { decode as magnetUriDecode } from 'magnet-uri'
|
||||||
import { basename } from 'path'
|
import { basename, extname } from 'path'
|
||||||
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
|
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
|
||||||
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
|
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
|
@ -25,6 +25,9 @@ import {
|
||||||
VideoStreamingPlaylistType
|
VideoStreamingPlaylistType
|
||||||
} from '@shared/models'
|
} from '@shared/models'
|
||||||
import { getDurationFromActivityStream } from '../../activity'
|
import { getDurationFromActivityStream } from '../../activity'
|
||||||
|
import { isArray } from '@server/helpers/custom-validators/misc'
|
||||||
|
import { generateImageFilename } from '@server/helpers/image-utils'
|
||||||
|
import { arrayify } from '@shared/core-utils'
|
||||||
|
|
||||||
function getThumbnailFromIcons (videoObject: VideoObject) {
|
function getThumbnailFromIcons (videoObject: VideoObject) {
|
||||||
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
|
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
|
||||||
|
@ -166,6 +169,26 @@ function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObje
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
|
||||||
|
if (!isArray(videoObject.preview)) return undefined
|
||||||
|
|
||||||
|
const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard'))
|
||||||
|
if (!storyboard) return undefined
|
||||||
|
|
||||||
|
const url = arrayify(storyboard.url).find(u => u.mediaType === 'image/jpeg')
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: generateImageFilename(extname(url.href)),
|
||||||
|
totalHeight: url.height,
|
||||||
|
totalWidth: url.width,
|
||||||
|
spriteHeight: url.tileHeight,
|
||||||
|
spriteWidth: url.tileWidth,
|
||||||
|
spriteDuration: getDurationFromActivityStream(url.tileDuration),
|
||||||
|
fileUrl: url.href,
|
||||||
|
videoId: video.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
|
function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
|
||||||
const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
|
const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
|
||||||
? VideoPrivacy.PUBLIC
|
? VideoPrivacy.PUBLIC
|
||||||
|
@ -228,6 +251,7 @@ export {
|
||||||
|
|
||||||
getLiveAttributesFromObject,
|
getLiveAttributesFromObject,
|
||||||
getCaptionAttributesFromObject,
|
getCaptionAttributesFromObject,
|
||||||
|
getStoryboardAttributeFromObject,
|
||||||
|
|
||||||
getVideoAttributesFromObject
|
getVideoAttributesFromObject
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,6 +57,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
|
runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
|
||||||
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
|
runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
|
||||||
|
runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
|
||||||
this.setOrDeleteLive(videoUpdated),
|
this.setOrDeleteLive(videoUpdated),
|
||||||
this.setPreview(videoUpdated)
|
this.setPreview(videoUpdated)
|
||||||
])
|
])
|
||||||
|
@ -138,6 +139,10 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
|
||||||
await this.insertOrReplaceCaptions(videoUpdated, t)
|
await this.insertOrReplaceCaptions(videoUpdated, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) {
|
||||||
|
await this.insertOrReplaceStoryboard(videoUpdated, t)
|
||||||
|
}
|
||||||
|
|
||||||
private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
|
private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
|
||||||
if (!this.video.isLive) return
|
if (!this.video.isLive) return
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './videos-preview-cache'
|
|
||||||
export * from './videos-caption-cache'
|
export * from './videos-caption-cache'
|
||||||
|
export * from './videos-preview-cache'
|
||||||
|
export * from './videos-storyboard-cache'
|
||||||
export * from './videos-torrent-cache'
|
export * from './videos-torrent-cache'
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { join } from 'path'
|
||||||
|
import { logger } from '@server/helpers/logger'
|
||||||
|
import { doRequestAndSaveToFile } from '@server/helpers/requests'
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard'
|
||||||
|
import { FILES_CACHE } from '../../initializers/constants'
|
||||||
|
import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
|
||||||
|
|
||||||
|
class VideosStoryboardCache extends AbstractVideoStaticFileCache <string> {
|
||||||
|
|
||||||
|
private static instance: VideosStoryboardCache
|
||||||
|
|
||||||
|
private constructor () {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
static get Instance () {
|
||||||
|
return this.instance || (this.instance = new this())
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFilePathImpl (filename: string) {
|
||||||
|
const storyboard = await StoryboardModel.loadWithVideoByFilename(filename)
|
||||||
|
if (!storyboard) return undefined
|
||||||
|
|
||||||
|
if (storyboard.Video.isOwned()) return { isOwned: true, path: storyboard.getPath() }
|
||||||
|
|
||||||
|
return this.loadRemoteFile(storyboard.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is the storyboard filename
|
||||||
|
protected async loadRemoteFile (key: string) {
|
||||||
|
const storyboard = await StoryboardModel.loadWithVideoByFilename(key)
|
||||||
|
if (!storyboard) return undefined
|
||||||
|
|
||||||
|
const destPath = join(FILES_CACHE.STORYBOARDS.DIRECTORY, storyboard.filename)
|
||||||
|
const remoteUrl = storyboard.getOriginFileUrl(storyboard.Video)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||||
|
|
||||||
|
logger.debug('Fetched remote storyboard %s to %s.', remoteUrl, destPath)
|
||||||
|
|
||||||
|
return { isOwned: false, path: destPath }
|
||||||
|
} catch (err) {
|
||||||
|
logger.info('Cannot fetch remote storyboard file %s.', remoteUrl, { err })
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
VideosStoryboardCache
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Job } from 'bullmq'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
|
||||||
|
import { generateImageFilename, getImageSize } from '@server/helpers/image-utils'
|
||||||
|
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||||
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { STORYBOARD } from '@server/initializers/constants'
|
||||||
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
||||||
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard'
|
||||||
|
import { VideoModel } from '@server/models/video/video'
|
||||||
|
import { MVideo } from '@server/types/models'
|
||||||
|
import { FFmpegImage, isAudioFile } from '@shared/ffmpeg'
|
||||||
|
import { GenerateStoryboardPayload } from '@shared/models'
|
||||||
|
|
||||||
|
const lTagsBase = loggerTagsFactory('storyboard')
|
||||||
|
|
||||||
|
async function processGenerateStoryboard (job: Job): Promise<void> {
|
||||||
|
const payload = job.data as GenerateStoryboardPayload
|
||||||
|
const lTags = lTagsBase(payload.videoUUID)
|
||||||
|
|
||||||
|
logger.info('Processing generate storyboard of %s in job %s.', payload.videoUUID, job.id, lTags)
|
||||||
|
|
||||||
|
const video = await VideoModel.loadFull(payload.videoUUID)
|
||||||
|
if (!video) {
|
||||||
|
logger.info('Video %s does not exist anymore, skipping storyboard generation.', payload.videoUUID, lTags)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputFile = video.getMaxQualityFile()
|
||||||
|
|
||||||
|
await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
|
||||||
|
const isAudio = await isAudioFile(videoPath)
|
||||||
|
|
||||||
|
if (isAudio) {
|
||||||
|
logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail'))
|
||||||
|
|
||||||
|
const filename = generateImageFilename()
|
||||||
|
const destination = join(CONFIG.STORAGE.STORYBOARDS_DIR, filename)
|
||||||
|
|
||||||
|
const totalSprites = buildTotalSprites(video)
|
||||||
|
const spriteDuration = Math.round(video.duration / totalSprites)
|
||||||
|
|
||||||
|
const spritesCount = findGridSize({
|
||||||
|
toFind: totalSprites,
|
||||||
|
maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Generating storyboard from video of %s to %s', video.uuid, destination,
|
||||||
|
{ ...lTags, spritesCount, spriteDuration, videoDuration: video.duration }
|
||||||
|
)
|
||||||
|
|
||||||
|
await ffmpeg.generateStoryboardFromVideo({
|
||||||
|
destination,
|
||||||
|
path: videoPath,
|
||||||
|
sprites: {
|
||||||
|
size: STORYBOARD.SPRITE_SIZE,
|
||||||
|
count: spritesCount,
|
||||||
|
duration: spriteDuration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageSize = await getImageSize(destination)
|
||||||
|
|
||||||
|
const existing = await StoryboardModel.loadByVideo(video.id)
|
||||||
|
if (existing) await existing.destroy()
|
||||||
|
|
||||||
|
await StoryboardModel.create({
|
||||||
|
filename,
|
||||||
|
totalHeight: imageSize.height,
|
||||||
|
totalWidth: imageSize.width,
|
||||||
|
spriteHeight: STORYBOARD.SPRITE_SIZE.height,
|
||||||
|
spriteWidth: STORYBOARD.SPRITE_SIZE.width,
|
||||||
|
spriteDuration,
|
||||||
|
videoId: video.id
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Storyboard generation %s ended for video %s.', destination, video.uuid, lTags)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payload.federate) {
|
||||||
|
await federateVideoIfNeeded(video, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
processGenerateStoryboard
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTotalSprites (video: MVideo) {
|
||||||
|
const maxSprites = STORYBOARD.SPRITE_SIZE.height * STORYBOARD.SPRITE_SIZE.width
|
||||||
|
const totalSprites = Math.min(Math.ceil(video.duration), maxSprites)
|
||||||
|
|
||||||
|
// We can generate a single line
|
||||||
|
if (totalSprites <= STORYBOARD.SPRITES_MAX_EDGE_COUNT) return totalSprites
|
||||||
|
|
||||||
|
return findGridFit(totalSprites, STORYBOARD.SPRITES_MAX_EDGE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGridSize (options: {
|
||||||
|
toFind: number
|
||||||
|
maxEdgeCount: number
|
||||||
|
}) {
|
||||||
|
const { toFind, maxEdgeCount } = options
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxEdgeCount; i++) {
|
||||||
|
for (let j = i; j <= maxEdgeCount; j++) {
|
||||||
|
if (toFind === i * j) return { width: j, height: i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not find grid size (to find: ${toFind}, max edge count: ${maxEdgeCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGridFit (value: number, maxMultiplier: number) {
|
||||||
|
for (let i = value; i--; i > 0) {
|
||||||
|
if (!isPrimeWithin(i, maxMultiplier)) return i
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not find prime number below ' + value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimeWithin (value: number, maxMultiplier: number) {
|
||||||
|
if (value < 2) return false
|
||||||
|
|
||||||
|
for (let i = 2, end = Math.min(Math.sqrt(value), maxMultiplier); i <= end; i++) {
|
||||||
|
if (value % i === 0 && value / i <= maxMultiplier) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
|
@ -306,6 +306,15 @@ async function afterImportSuccess (options: {
|
||||||
Notifier.Instance.notifyOnNewVideoIfNeeded(video)
|
Notifier.Instance.notifyOnNewVideoIfNeeded(video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate the storyboard in the job queue, and don't forget to federate an update after
|
||||||
|
await JobQueue.Instance.createJob({
|
||||||
|
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
federate: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
|
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
|
||||||
await JobQueue.Instance.createJob(
|
await JobQueue.Instance.createJob(
|
||||||
await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT })
|
await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT })
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { Job } from 'bullmq'
|
import { Job } from 'bullmq'
|
||||||
import { readdir, remove } from 'fs-extra'
|
import { readdir, remove } from 'fs-extra'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { peertubeTruncate } from '@server/helpers/core-utils'
|
||||||
|
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
|
||||||
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
|
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
|
||||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
|
||||||
import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
|
import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
|
||||||
|
@ -20,8 +22,7 @@ import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@serv
|
||||||
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg'
|
import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg'
|
||||||
import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
|
import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
|
||||||
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
import { logger, loggerTagsFactory } from '../../../helpers/logger'
|
||||||
import { peertubeTruncate } from '@server/helpers/core-utils'
|
import { JobQueue } from '../job-queue'
|
||||||
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
|
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('live', 'job')
|
const lTags = loggerTagsFactory('live', 'job')
|
||||||
|
|
||||||
|
@ -147,6 +148,8 @@ async function saveReplayToExternalVideo (options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
await moveToNextState({ video: replayVideo, isNewVideo: true })
|
await moveToNextState({ video: replayVideo, isNewVideo: true })
|
||||||
|
|
||||||
|
await createStoryboardJob(replayVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceLiveByReplay (options: {
|
async function replaceLiveByReplay (options: {
|
||||||
|
@ -186,6 +189,7 @@ async function replaceLiveByReplay (options: {
|
||||||
|
|
||||||
await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
|
await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
|
||||||
|
|
||||||
|
// FIXME: should not happen in this function
|
||||||
if (permanentLive) { // Remove session replay
|
if (permanentLive) { // Remove session replay
|
||||||
await remove(replayDirectory)
|
await remove(replayDirectory)
|
||||||
} else { // We won't stream again in this live, we can delete the base replay directory
|
} else { // We won't stream again in this live, we can delete the base replay directory
|
||||||
|
@ -213,6 +217,8 @@ async function replaceLiveByReplay (options: {
|
||||||
|
|
||||||
// We consider this is a new video
|
// We consider this is a new video
|
||||||
await moveToNextState({ video: videoWithFiles, isNewVideo: true })
|
await moveToNextState({ video: videoWithFiles, isNewVideo: true })
|
||||||
|
|
||||||
|
await createStoryboardJob(videoWithFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assignReplayFilesToVideo (options: {
|
async function assignReplayFilesToVideo (options: {
|
||||||
|
@ -277,3 +283,13 @@ async function cleanupLiveAndFederate (options: {
|
||||||
logger.warn('Cannot federate live after cleanup', { videoId: video.id, err })
|
logger.warn('Cannot federate live after cleanup', { videoId: video.id, err })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStoryboardJob (video: MVideo) {
|
||||||
|
return JobQueue.Instance.createJob({
|
||||||
|
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
federate: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ import {
|
||||||
DeleteResumableUploadMetaFilePayload,
|
DeleteResumableUploadMetaFilePayload,
|
||||||
EmailPayload,
|
EmailPayload,
|
||||||
FederateVideoPayload,
|
FederateVideoPayload,
|
||||||
|
GenerateStoryboardPayload,
|
||||||
JobState,
|
JobState,
|
||||||
JobType,
|
JobType,
|
||||||
ManageVideoTorrentPayload,
|
ManageVideoTorrentPayload,
|
||||||
|
@ -65,6 +66,7 @@ import { processVideoLiveEnding } from './handlers/video-live-ending'
|
||||||
import { processVideoStudioEdition } from './handlers/video-studio-edition'
|
import { processVideoStudioEdition } from './handlers/video-studio-edition'
|
||||||
import { processVideoTranscoding } from './handlers/video-transcoding'
|
import { processVideoTranscoding } from './handlers/video-transcoding'
|
||||||
import { processVideosViewsStats } from './handlers/video-views-stats'
|
import { processVideosViewsStats } from './handlers/video-views-stats'
|
||||||
|
import { processGenerateStoryboard } from './handlers/generate-storyboard'
|
||||||
|
|
||||||
export type CreateJobArgument =
|
export type CreateJobArgument =
|
||||||
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
|
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
|
||||||
|
@ -91,7 +93,8 @@ export type CreateJobArgument =
|
||||||
{ type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } |
|
{ type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } |
|
||||||
{ type: 'notify', payload: NotifyPayload } |
|
{ type: 'notify', payload: NotifyPayload } |
|
||||||
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
|
{ type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
|
||||||
{ type: 'federate-video', payload: FederateVideoPayload }
|
{ type: 'federate-video', payload: FederateVideoPayload } |
|
||||||
|
{ type: 'generate-video-storyboard', payload: GenerateStoryboardPayload }
|
||||||
|
|
||||||
export type CreateJobOptions = {
|
export type CreateJobOptions = {
|
||||||
delay?: number
|
delay?: number
|
||||||
|
@ -122,7 +125,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
|
||||||
'video-redundancy': processVideoRedundancy,
|
'video-redundancy': processVideoRedundancy,
|
||||||
'video-studio-edition': processVideoStudioEdition,
|
'video-studio-edition': processVideoStudioEdition,
|
||||||
'video-transcoding': processVideoTranscoding,
|
'video-transcoding': processVideoTranscoding,
|
||||||
'videos-views-stats': processVideosViewsStats
|
'videos-views-stats': processVideosViewsStats,
|
||||||
|
'generate-video-storyboard': processGenerateStoryboard
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
|
const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
|
||||||
|
@ -141,10 +145,11 @@ const jobTypes: JobType[] = [
|
||||||
'after-video-channel-import',
|
'after-video-channel-import',
|
||||||
'email',
|
'email',
|
||||||
'federate-video',
|
'federate-video',
|
||||||
'transcoding-job-builder',
|
'generate-video-storyboard',
|
||||||
'manage-video-torrent',
|
'manage-video-torrent',
|
||||||
'move-to-object-storage',
|
'move-to-object-storage',
|
||||||
'notify',
|
'notify',
|
||||||
|
'transcoding-job-builder',
|
||||||
'video-channel-import',
|
'video-channel-import',
|
||||||
'video-file-import',
|
'video-file-import',
|
||||||
'video-import',
|
'video-import',
|
||||||
|
|
|
@ -325,8 +325,8 @@ class Redis {
|
||||||
const value = await this.getValue('resumable-upload-' + uploadId)
|
const value = await this.getValue('resumable-upload-' + uploadId)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
? JSON.parse(value)
|
? JSON.parse(value) as { video: { id: number, shortUUID: string, uuid: string } }
|
||||||
: ''
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteUploadSession (uploadId: string) {
|
deleteUploadSession (uploadId: string) {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVOD
|
||||||
import { VideoResolution, VideoStorage } from '@shared/models'
|
import { VideoResolution, VideoStorage } from '@shared/models'
|
||||||
import { CONFIG } from '../../initializers/config'
|
import { CONFIG } from '../../initializers/config'
|
||||||
import { VideoFileModel } from '../../models/video/video-file'
|
import { VideoFileModel } from '../../models/video/video-file'
|
||||||
|
import { JobQueue } from '../job-queue'
|
||||||
import { generateWebTorrentVideoFilename } from '../paths'
|
import { generateWebTorrentVideoFilename } from '../paths'
|
||||||
import { buildFileMetadata } from '../video-file'
|
import { buildFileMetadata } from '../video-file'
|
||||||
import { VideoPathManager } from '../video-path-manager'
|
import { VideoPathManager } from '../video-path-manager'
|
||||||
|
@ -198,7 +199,8 @@ export async function mergeAudioVideofile (options: {
|
||||||
return onWebTorrentVideoFileTranscoding({
|
return onWebTorrentVideoFileTranscoding({
|
||||||
video,
|
video,
|
||||||
videoFile: inputVideoFile,
|
videoFile: inputVideoFile,
|
||||||
videoOutputPath
|
videoOutputPath,
|
||||||
|
wasAudioFile: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -212,8 +214,9 @@ export async function onWebTorrentVideoFileTranscoding (options: {
|
||||||
video: MVideoFullLight
|
video: MVideoFullLight
|
||||||
videoFile: MVideoFile
|
videoFile: MVideoFile
|
||||||
videoOutputPath: string
|
videoOutputPath: string
|
||||||
|
wasAudioFile?: boolean // default false
|
||||||
}) {
|
}) {
|
||||||
const { video, videoFile, videoOutputPath } = options
|
const { video, videoFile, videoOutputPath, wasAudioFile } = options
|
||||||
|
|
||||||
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
|
||||||
|
|
||||||
|
@ -242,6 +245,17 @@ export async function onWebTorrentVideoFileTranscoding (options: {
|
||||||
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
|
await VideoFileModel.customUpsert(videoFile, 'video', undefined)
|
||||||
video.VideoFiles = await video.$get('VideoFiles')
|
video.VideoFiles = await video.$get('VideoFiles')
|
||||||
|
|
||||||
|
if (wasAudioFile) {
|
||||||
|
await JobQueue.Instance.createJob({
|
||||||
|
type: 'generate-video-storyboard' as 'generate-video-storyboard',
|
||||||
|
payload: {
|
||||||
|
videoUUID: video.uuid,
|
||||||
|
// No need to federate, we process these jobs sequentially
|
||||||
|
federate: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { video, videoFile }
|
return { video, videoFile }
|
||||||
} finally {
|
} finally {
|
||||||
mutexReleaser()
|
mutexReleaser()
|
||||||
|
|
|
@ -25,6 +25,7 @@ const customConfigUpdateValidator = [
|
||||||
body('cache.previews.size').isInt(),
|
body('cache.previews.size').isInt(),
|
||||||
body('cache.captions.size').isInt(),
|
body('cache.captions.size').isInt(),
|
||||||
body('cache.torrents.size').isInt(),
|
body('cache.torrents.size').isInt(),
|
||||||
|
body('cache.storyboards.size').isInt(),
|
||||||
|
|
||||||
body('signup.enabled').isBoolean(),
|
body('signup.enabled').isBoolean(),
|
||||||
body('signup.limit').isInt(),
|
body('signup.limit').isInt(),
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
|
||||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager'
|
import { VideoViewsManager } from '@server/lib/views/video-views-manager'
|
||||||
import { uuidToShort } from '@shared/extra-utils'
|
import { uuidToShort } from '@shared/extra-utils'
|
||||||
import {
|
import {
|
||||||
|
ActivityPubStoryboard,
|
||||||
ActivityTagObject,
|
ActivityTagObject,
|
||||||
ActivityUrlObject,
|
ActivityUrlObject,
|
||||||
Video,
|
Video,
|
||||||
|
@ -347,29 +348,17 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||||
name: t.name
|
name: t.name
|
||||||
}))
|
}))
|
||||||
|
|
||||||
let language
|
const language = video.language
|
||||||
if (video.language) {
|
? { identifier: video.language, name: getLanguageLabel(video.language) }
|
||||||
language = {
|
: undefined
|
||||||
identifier: video.language,
|
|
||||||
name: getLanguageLabel(video.language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let category
|
const category = video.category
|
||||||
if (video.category) {
|
? { identifier: video.category + '', name: getCategoryLabel(video.category) }
|
||||||
category = {
|
: undefined
|
||||||
identifier: video.category + '',
|
|
||||||
name: getCategoryLabel(video.category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let licence
|
const licence = video.licence
|
||||||
if (video.licence) {
|
? { identifier: video.licence + '', name: getLicenceLabel(video.licence) }
|
||||||
licence = {
|
: undefined
|
||||||
identifier: video.licence + '',
|
|
||||||
name: getLicenceLabel(video.licence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url: ActivityUrlObject[] = [
|
const url: ActivityUrlObject[] = [
|
||||||
// HTML url should be the first element in the array so Mastodon correctly displays the embed
|
// HTML url should be the first element in the array so Mastodon correctly displays the embed
|
||||||
|
@ -465,6 +454,8 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||||
height: i.height
|
height: i.height
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
preview: buildPreviewAPAttribute(video),
|
||||||
|
|
||||||
url,
|
url,
|
||||||
|
|
||||||
likes: getLocalVideoLikesActivityPubUrl(video),
|
likes: getLocalVideoLikesActivityPubUrl(video),
|
||||||
|
@ -541,3 +532,30 @@ function buildLiveAPAttributes (video: MVideoAP) {
|
||||||
latencyMode: video.VideoLive.latencyMode
|
latencyMode: video.VideoLive.latencyMode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] {
|
||||||
|
if (!video.Storyboard) return undefined
|
||||||
|
|
||||||
|
const storyboard = video.Storyboard
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'Image',
|
||||||
|
rel: [ 'storyboard' ],
|
||||||
|
url: [
|
||||||
|
{
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
|
||||||
|
href: storyboard.getOriginFileUrl(video),
|
||||||
|
|
||||||
|
width: storyboard.totalWidth,
|
||||||
|
height: storyboard.totalHeight,
|
||||||
|
|
||||||
|
tileWidth: storyboard.spriteWidth,
|
||||||
|
tileHeight: storyboard.spriteHeight,
|
||||||
|
tileDuration: getActivityStreamDuration(storyboard.spriteDuration)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { remove } from 'fs-extra'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||||
|
import { CONFIG } from '@server/initializers/config'
|
||||||
|
import { MStoryboard, MStoryboardVideo, MVideo } from '@server/types/models'
|
||||||
|
import { Storyboard } from '@shared/models'
|
||||||
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
|
import { logger } from '../../helpers/logger'
|
||||||
|
import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
|
||||||
|
import { VideoModel } from './video'
|
||||||
|
import { Transaction } from 'sequelize'
|
||||||
|
|
||||||
|
@Table({
|
||||||
|
tableName: 'storyboard',
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: [ 'videoId' ],
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: [ 'filename' ],
|
||||||
|
unique: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class StoryboardModel extends Model<Partial<AttributesOnly<StoryboardModel>>> {
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
filename: string
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
totalHeight: number
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
totalWidth: number
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
spriteHeight: number
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
spriteWidth: number
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
spriteDuration: number
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
|
||||||
|
fileUrl: string
|
||||||
|
|
||||||
|
@ForeignKey(() => VideoModel)
|
||||||
|
@Column
|
||||||
|
videoId: number
|
||||||
|
|
||||||
|
@BelongsTo(() => VideoModel, {
|
||||||
|
foreignKey: {
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
})
|
||||||
|
Video: VideoModel
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
createdAt: Date
|
||||||
|
|
||||||
|
@UpdatedAt
|
||||||
|
updatedAt: Date
|
||||||
|
|
||||||
|
@AfterDestroy
|
||||||
|
static removeInstanceFile (instance: StoryboardModel) {
|
||||||
|
logger.info('Removing storyboard file %s.', instance.filename)
|
||||||
|
|
||||||
|
// Don't block the transaction
|
||||||
|
instance.removeFile()
|
||||||
|
.catch(err => logger.error('Cannot remove storyboard file %s.', instance.filename, { err }))
|
||||||
|
}
|
||||||
|
|
||||||
|
static loadByVideo (videoId: number, transaction?: Transaction): Promise<MStoryboard> {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
videoId
|
||||||
|
},
|
||||||
|
transaction
|
||||||
|
}
|
||||||
|
|
||||||
|
return StoryboardModel.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
static loadByFilename (filename: string): Promise<MStoryboard> {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StoryboardModel.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
static loadWithVideoByFilename (filename: string): Promise<MStoryboardVideo> {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
filename
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: VideoModel.unscoped(),
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return StoryboardModel.findOne(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static async listStoryboardsOf (video: MVideo): Promise<MStoryboardVideo[]> {
|
||||||
|
const query = {
|
||||||
|
where: {
|
||||||
|
videoId: video.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storyboards = await StoryboardModel.findAll<MStoryboard>(query)
|
||||||
|
|
||||||
|
return storyboards.map(s => Object.assign(s, { Video: video }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getOriginFileUrl (video: MVideo) {
|
||||||
|
if (video.isOwned()) {
|
||||||
|
return WEBSERVER.URL + this.getLocalStaticPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fileUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalStaticPath () {
|
||||||
|
return LAZY_STATIC_PATHS.STORYBOARDS + this.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
getPath () {
|
||||||
|
return join(CONFIG.STORAGE.STORYBOARDS_DIR, this.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFile () {
|
||||||
|
return remove(this.getPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
toFormattedJSON (this: MStoryboardVideo): Storyboard {
|
||||||
|
return {
|
||||||
|
storyboardPath: this.getLocalStaticPath(),
|
||||||
|
|
||||||
|
totalHeight: this.totalHeight,
|
||||||
|
totalWidth: this.totalWidth,
|
||||||
|
|
||||||
|
spriteWidth: this.spriteWidth,
|
||||||
|
spriteHeight: this.spriteHeight,
|
||||||
|
|
||||||
|
spriteDuration: this.spriteDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,7 +15,7 @@ import {
|
||||||
Table,
|
Table,
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models'
|
import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, MVideoCaptionVideo } from '@server/types/models'
|
||||||
import { buildUUID } from '@shared/extra-utils'
|
import { buildUUID } from '@shared/extra-utils'
|
||||||
import { AttributesOnly } from '@shared/typescript-utils'
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
|
import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
|
||||||
|
@ -225,7 +225,7 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCaptionStaticPath (this: MVideoCaption) {
|
getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
|
||||||
return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
|
return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -233,9 +233,7 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
|
||||||
return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename)
|
return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileUrl (video: MVideo) {
|
getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideo) {
|
||||||
if (!this.Video) this.Video = video as VideoModel
|
|
||||||
|
|
||||||
if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
|
if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
|
||||||
|
|
||||||
return this.fileUrl
|
return this.fileUrl
|
||||||
|
|
|
@ -58,7 +58,7 @@ import {
|
||||||
import { AttributesOnly } from '@shared/typescript-utils'
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
import { peertubeTruncate } from '../../helpers/core-utils'
|
import { peertubeTruncate } from '../../helpers/core-utils'
|
||||||
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
|
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
|
||||||
import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
|
import { exists, isArray, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
|
||||||
import {
|
import {
|
||||||
isVideoDescriptionValid,
|
isVideoDescriptionValid,
|
||||||
isVideoDurationValid,
|
isVideoDurationValid,
|
||||||
|
@ -75,6 +75,7 @@ import {
|
||||||
MChannel,
|
MChannel,
|
||||||
MChannelAccountDefault,
|
MChannelAccountDefault,
|
||||||
MChannelId,
|
MChannelId,
|
||||||
|
MStoryboard,
|
||||||
MStreamingPlaylist,
|
MStreamingPlaylist,
|
||||||
MStreamingPlaylistFilesVideo,
|
MStreamingPlaylistFilesVideo,
|
||||||
MUserAccountId,
|
MUserAccountId,
|
||||||
|
@ -83,6 +84,8 @@ import {
|
||||||
MVideoAccountLight,
|
MVideoAccountLight,
|
||||||
MVideoAccountLightBlacklistAllFiles,
|
MVideoAccountLightBlacklistAllFiles,
|
||||||
MVideoAP,
|
MVideoAP,
|
||||||
|
MVideoAPLight,
|
||||||
|
MVideoCaptionLanguageUrl,
|
||||||
MVideoDetails,
|
MVideoDetails,
|
||||||
MVideoFileVideo,
|
MVideoFileVideo,
|
||||||
MVideoFormattable,
|
MVideoFormattable,
|
||||||
|
@ -126,6 +129,7 @@ import {
|
||||||
VideosIdListQueryBuilder,
|
VideosIdListQueryBuilder,
|
||||||
VideosModelListQueryBuilder
|
VideosModelListQueryBuilder
|
||||||
} from './sql/video'
|
} from './sql/video'
|
||||||
|
import { StoryboardModel } from './storyboard'
|
||||||
import { TagModel } from './tag'
|
import { TagModel } from './tag'
|
||||||
import { ThumbnailModel } from './thumbnail'
|
import { ThumbnailModel } from './thumbnail'
|
||||||
import { VideoBlacklistModel } from './video-blacklist'
|
import { VideoBlacklistModel } from './video-blacklist'
|
||||||
|
@ -753,6 +757,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
})
|
})
|
||||||
VideoJobInfo: VideoJobInfoModel
|
VideoJobInfo: VideoJobInfoModel
|
||||||
|
|
||||||
|
@HasOne(() => StoryboardModel, {
|
||||||
|
foreignKey: {
|
||||||
|
name: 'videoId',
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
onDelete: 'cascade'
|
||||||
|
})
|
||||||
|
Storyboard: StoryboardModel
|
||||||
|
|
||||||
@AfterCreate
|
@AfterCreate
|
||||||
static notifyCreate (video: MVideo) {
|
static notifyCreate (video: MVideo) {
|
||||||
InternalEventEmitter.Instance.emit('video-created', { video })
|
InternalEventEmitter.Instance.emit('video-created', { video })
|
||||||
|
@ -903,6 +916,10 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
model: VideoCaptionModel.unscoped(),
|
model: VideoCaptionModel.unscoped(),
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: StoryboardModel.unscoped(),
|
||||||
|
required: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
attributes: [ 'id', 'url' ],
|
attributes: [ 'id', 'url' ],
|
||||||
model: VideoShareModel.unscoped(),
|
model: VideoShareModel.unscoped(),
|
||||||
|
@ -1768,6 +1785,32 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async lightAPToFullAP (this: MVideoAPLight, transaction: Transaction): Promise<MVideoAP> {
|
||||||
|
const videoAP = this as MVideoAP
|
||||||
|
|
||||||
|
const getCaptions = () => {
|
||||||
|
if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
|
||||||
|
|
||||||
|
return this.$get('VideoCaptions', {
|
||||||
|
attributes: [ 'filename', 'language', 'fileUrl' ],
|
||||||
|
transaction
|
||||||
|
}) as Promise<MVideoCaptionLanguageUrl[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoryboard = () => {
|
||||||
|
if (videoAP.Storyboard) return videoAP.Storyboard
|
||||||
|
|
||||||
|
return this.$get('Storyboard', { transaction }) as Promise<MStoryboard>
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ captions, storyboard ] = await Promise.all([ getCaptions(), getStoryboard() ])
|
||||||
|
|
||||||
|
return Object.assign(this, {
|
||||||
|
VideoCaptions: captions,
|
||||||
|
Storyboard: storyboard
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getTruncatedDescription () {
|
getTruncatedDescription () {
|
||||||
if (!this.description) return null
|
if (!this.description) return null
|
||||||
|
|
||||||
|
|
|
@ -74,6 +74,9 @@ describe('Test config API validators', function () {
|
||||||
},
|
},
|
||||||
torrents: {
|
torrents: {
|
||||||
size: 4
|
size: 4
|
||||||
|
},
|
||||||
|
storyboards: {
|
||||||
|
size: 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
|
|
|
@ -34,6 +34,7 @@ import './video-comments'
|
||||||
import './video-files'
|
import './video-files'
|
||||||
import './video-imports'
|
import './video-imports'
|
||||||
import './video-playlists'
|
import './video-playlists'
|
||||||
|
import './video-storyboards'
|
||||||
import './video-source'
|
import './video-source'
|
||||||
import './video-studio'
|
import './video-studio'
|
||||||
import './video-token'
|
import './video-token'
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { HttpStatusCode, VideoPrivacy } from '@shared/models'
|
||||||
|
import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
|
||||||
|
|
||||||
|
describe('Test video storyboards API validator', function () {
|
||||||
|
let server: PeerTubeServer
|
||||||
|
|
||||||
|
let publicVideo: { uuid: string }
|
||||||
|
let privateVideo: { uuid: string }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(30000)
|
||||||
|
|
||||||
|
server = await createSingleServer(1)
|
||||||
|
await setAccessTokensToServers([ server ])
|
||||||
|
|
||||||
|
publicVideo = await server.videos.quickUpload({ name: 'public' })
|
||||||
|
privateVideo = await server.videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail without a valid uuid', async function () {
|
||||||
|
await server.storyboard.list({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should receive 404 when passing a non existing video id', async function () {
|
||||||
|
await server.storyboard.list({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should not get the private storyboard without the appropriate token', async function () {
|
||||||
|
await server.storyboard.list({ id: privateVideo.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null })
|
||||||
|
await server.storyboard.list({ id: publicVideo.uuid, expectedStatus: HttpStatusCode.OK_200, token: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should succeed with the correct parameters', async function () {
|
||||||
|
await server.storyboard.list({ id: privateVideo.uuid })
|
||||||
|
await server.storyboard.list({ id: publicVideo.uuid })
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { cleanupTests, createSingleServer, PeerTubeServer } from '@shared/server-commands'
|
import { cleanupTests, createSingleServer, PeerTubeServer } from '@shared/server-commands'
|
||||||
|
|
||||||
describe('Test videos overview', function () {
|
describe('Test videos overview API validator', function () {
|
||||||
let server: PeerTubeServer
|
let server: PeerTubeServer
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
|
@ -46,6 +46,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
|
||||||
expect(data.cache.previews.size).to.equal(1)
|
expect(data.cache.previews.size).to.equal(1)
|
||||||
expect(data.cache.captions.size).to.equal(1)
|
expect(data.cache.captions.size).to.equal(1)
|
||||||
expect(data.cache.torrents.size).to.equal(1)
|
expect(data.cache.torrents.size).to.equal(1)
|
||||||
|
expect(data.cache.storyboards.size).to.equal(1)
|
||||||
|
|
||||||
expect(data.signup.enabled).to.be.true
|
expect(data.signup.enabled).to.be.true
|
||||||
expect(data.signup.limit).to.equal(4)
|
expect(data.signup.limit).to.equal(4)
|
||||||
|
@ -154,6 +155,7 @@ function checkUpdatedConfig (data: CustomConfig) {
|
||||||
expect(data.cache.previews.size).to.equal(2)
|
expect(data.cache.previews.size).to.equal(2)
|
||||||
expect(data.cache.captions.size).to.equal(3)
|
expect(data.cache.captions.size).to.equal(3)
|
||||||
expect(data.cache.torrents.size).to.equal(4)
|
expect(data.cache.torrents.size).to.equal(4)
|
||||||
|
expect(data.cache.storyboards.size).to.equal(5)
|
||||||
|
|
||||||
expect(data.signup.enabled).to.be.false
|
expect(data.signup.enabled).to.be.false
|
||||||
expect(data.signup.limit).to.equal(5)
|
expect(data.signup.limit).to.equal(5)
|
||||||
|
@ -290,6 +292,9 @@ const newCustomConfig: CustomConfig = {
|
||||||
},
|
},
|
||||||
torrents: {
|
torrents: {
|
||||||
size: 4
|
size: 4
|
||||||
|
},
|
||||||
|
storyboards: {
|
||||||
|
size: 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
|
|
|
@ -20,3 +20,4 @@ import './videos-history'
|
||||||
import './videos-overview'
|
import './videos-overview'
|
||||||
import './video-source'
|
import './video-source'
|
||||||
import './video-static-file-privacy'
|
import './video-static-file-privacy'
|
||||||
|
import './video-storyboard'
|
||||||
|
|
|
@ -0,0 +1,184 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
|
||||||
|
|
||||||
|
import { expect } from 'chai'
|
||||||
|
import { FIXTURE_URLS } from '@server/tests/shared'
|
||||||
|
import { areHttpImportTestsDisabled } from '@shared/core-utils'
|
||||||
|
import { HttpStatusCode, VideoPrivacy } from '@shared/models'
|
||||||
|
import {
|
||||||
|
cleanupTests,
|
||||||
|
createMultipleServers,
|
||||||
|
doubleFollow,
|
||||||
|
makeGetRequest,
|
||||||
|
PeerTubeServer,
|
||||||
|
sendRTMPStream,
|
||||||
|
setAccessTokensToServers,
|
||||||
|
setDefaultVideoChannel,
|
||||||
|
stopFfmpeg,
|
||||||
|
waitJobs
|
||||||
|
} from '@shared/server-commands'
|
||||||
|
|
||||||
|
async function checkStoryboard (options: {
|
||||||
|
server: PeerTubeServer
|
||||||
|
uuid: string
|
||||||
|
tilesCount?: number
|
||||||
|
minSize?: number
|
||||||
|
}) {
|
||||||
|
const { server, uuid, tilesCount, minSize = 1000 } = options
|
||||||
|
|
||||||
|
const { storyboards } = await server.storyboard.list({ id: uuid })
|
||||||
|
|
||||||
|
expect(storyboards).to.have.lengthOf(1)
|
||||||
|
|
||||||
|
const storyboard = storyboards[0]
|
||||||
|
|
||||||
|
expect(storyboard.spriteDuration).to.equal(1)
|
||||||
|
expect(storyboard.spriteHeight).to.equal(108)
|
||||||
|
expect(storyboard.spriteWidth).to.equal(192)
|
||||||
|
expect(storyboard.storyboardPath).to.exist
|
||||||
|
|
||||||
|
if (tilesCount) {
|
||||||
|
expect(storyboard.totalWidth).to.equal(192 * Math.min(tilesCount, 10))
|
||||||
|
expect(storyboard.totalHeight).to.equal(108 * Math.max((tilesCount / 10), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
|
expect(body.length).to.be.above(minSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Test video storyboard', function () {
|
||||||
|
let servers: PeerTubeServer[]
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
servers = await createMultipleServers(2)
|
||||||
|
await setAccessTokensToServers(servers)
|
||||||
|
await setDefaultVideoChannel(servers)
|
||||||
|
|
||||||
|
await doubleFollow(servers[0], servers[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after upload without transcoding', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
// 5s video
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid, tilesCount: 5 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after upload without transcoding with a long video', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
// 124s video
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_very_long_10p.mp4' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid, tilesCount: 100 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after upload with transcoding', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
|
|
||||||
|
// 5s video
|
||||||
|
const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid, tilesCount: 5 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after an audio upload', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
// 6s audio
|
||||||
|
const attributes = { name: 'audio', fixture: 'sample.ogg' }
|
||||||
|
const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid, tilesCount: 6, minSize: 250 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after HTTP import', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
if (areHttpImportTestsDisabled()) return
|
||||||
|
|
||||||
|
// 3s video
|
||||||
|
const { video } = await servers[0].imports.importVideo({
|
||||||
|
attributes: {
|
||||||
|
targetUrl: FIXTURE_URLS.goodVideo,
|
||||||
|
channelId: servers[0].store.channel.id,
|
||||||
|
privacy: VideoPrivacy.PUBLIC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid: video.uuid, tilesCount: 3 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after torrent import', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
if (areHttpImportTestsDisabled()) return
|
||||||
|
|
||||||
|
// 10s video
|
||||||
|
const { video } = await servers[0].imports.importVideo({
|
||||||
|
attributes: {
|
||||||
|
magnetUri: FIXTURE_URLS.magnet,
|
||||||
|
channelId: servers[0].store.channel.id,
|
||||||
|
privacy: VideoPrivacy.PUBLIC
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid: video.uuid, tilesCount: 10 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard after a live', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
|
||||||
|
|
||||||
|
const { live, video } = await servers[0].live.quickCreate({
|
||||||
|
saveReplay: true,
|
||||||
|
permanentLive: false,
|
||||||
|
privacy: VideoPrivacy.PUBLIC
|
||||||
|
})
|
||||||
|
|
||||||
|
const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
|
||||||
|
await servers[0].live.waitUntilPublished({ videoId: video.id })
|
||||||
|
|
||||||
|
await stopFfmpeg(ffmpegCommand)
|
||||||
|
|
||||||
|
await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id })
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
await checkStoryboard({ server, uuid: video.uuid })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should generate a storyboard with different video durations', async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests(servers)
|
||||||
|
})
|
||||||
|
})
|
Binary file not shown.
|
@ -1,6 +1,7 @@
|
||||||
export * from './local-video-viewer-watch-section'
|
export * from './local-video-viewer-watch-section'
|
||||||
export * from './local-video-viewer-watch-section'
|
export * from './local-video-viewer-watch-section'
|
||||||
export * from './local-video-viewer'
|
export * from './local-video-viewer'
|
||||||
|
export * from './storyboard'
|
||||||
export * from './schedule-video-update'
|
export * from './schedule-video-update'
|
||||||
export * from './tag'
|
export * from './tag'
|
||||||
export * from './thumbnail'
|
export * from './thumbnail'
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { StoryboardModel } from '@server/models/video/storyboard'
|
||||||
|
import { PickWith } from '@shared/typescript-utils'
|
||||||
|
import { MVideo } from './video'
|
||||||
|
|
||||||
|
type Use<K extends keyof StoryboardModel, M> = PickWith<StoryboardModel, K, M>
|
||||||
|
|
||||||
|
// ############################################################################
|
||||||
|
|
||||||
|
export type MStoryboard = Omit<StoryboardModel, 'Video'>
|
||||||
|
|
||||||
|
// ############################################################################
|
||||||
|
|
||||||
|
export type MStoryboardVideo =
|
||||||
|
MStoryboard &
|
||||||
|
Use<'Video', MVideo>
|
|
@ -11,7 +11,7 @@ export type MVideoCaption = Omit<VideoCaptionModel, 'Video'>
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
export type MVideoCaptionLanguage = Pick<MVideoCaption, 'language'>
|
export type MVideoCaptionLanguage = Pick<MVideoCaption, 'language'>
|
||||||
export type MVideoCaptionLanguageUrl = Pick<MVideoCaption, 'language' | 'fileUrl' | 'getFileUrl'>
|
export type MVideoCaptionLanguageUrl = Pick<MVideoCaption, 'language' | 'fileUrl' | 'filename' | 'getFileUrl' | 'getCaptionStaticPath'>
|
||||||
|
|
||||||
export type MVideoCaptionVideo =
|
export type MVideoCaptionVideo =
|
||||||
MVideoCaption &
|
MVideoCaption &
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { VideoModel } from '../../../models/video/video'
|
||||||
import { MTrackerUrl } from '../server/tracker'
|
import { MTrackerUrl } from '../server/tracker'
|
||||||
import { MUserVideoHistoryTime } from '../user/user-video-history'
|
import { MUserVideoHistoryTime } from '../user/user-video-history'
|
||||||
import { MScheduleVideoUpdate } from './schedule-video-update'
|
import { MScheduleVideoUpdate } from './schedule-video-update'
|
||||||
|
import { MStoryboard } from './storyboard'
|
||||||
import { MTag } from './tag'
|
import { MTag } from './tag'
|
||||||
import { MThumbnail } from './thumbnail'
|
import { MThumbnail } from './thumbnail'
|
||||||
import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist'
|
import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist'
|
||||||
|
@ -32,7 +33,7 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
|
||||||
export type MVideo =
|
export type MVideo =
|
||||||
Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
|
Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
|
||||||
'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
|
'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
|
||||||
'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords'>
|
'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard'>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
|
@ -173,9 +174,10 @@ export type MVideoAP =
|
||||||
Use<'VideoBlacklist', MVideoBlacklistUnfederated> &
|
Use<'VideoBlacklist', MVideoBlacklistUnfederated> &
|
||||||
Use<'VideoFiles', MVideoFileRedundanciesOpt[]> &
|
Use<'VideoFiles', MVideoFileRedundanciesOpt[]> &
|
||||||
Use<'Thumbnails', MThumbnail[]> &
|
Use<'Thumbnails', MThumbnail[]> &
|
||||||
Use<'VideoLive', MVideoLive>
|
Use<'VideoLive', MVideoLive> &
|
||||||
|
Use<'Storyboard', MStoryboard>
|
||||||
|
|
||||||
export type MVideoAPWithoutCaption = Omit<MVideoAP, 'VideoCaptions'>
|
export type MVideoAPLight = Omit<MVideoAP, 'VideoCaptions' | 'Storyboard'>
|
||||||
|
|
||||||
export type MVideoDetails =
|
export type MVideoDetails =
|
||||||
MVideo &
|
MVideo &
|
||||||
|
|
|
@ -56,4 +56,41 @@ export class FFmpegImage {
|
||||||
.thumbnail(thumbnailOptions)
|
.thumbnail(thumbnailOptions)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateStoryboardFromVideo (options: {
|
||||||
|
path: string
|
||||||
|
destination: string
|
||||||
|
|
||||||
|
sprites: {
|
||||||
|
size: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
count: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
const { path, destination, sprites } = options
|
||||||
|
|
||||||
|
const command = this.commandWrapper.buildCommand(path)
|
||||||
|
|
||||||
|
const filter = [
|
||||||
|
`setpts=N/round(FRAME_RATE)/TB`,
|
||||||
|
`select='not(mod(t,${options.sprites.duration}))'`,
|
||||||
|
`scale=${sprites.size.width}:${sprites.size.height}`,
|
||||||
|
`tile=layout=${sprites.count.width}x${sprites.count.height}`
|
||||||
|
].join(',')
|
||||||
|
|
||||||
|
command.outputOption('-filter_complex', filter)
|
||||||
|
command.outputOption('-frames:v', '1')
|
||||||
|
command.outputOption('-q:v', '2')
|
||||||
|
command.output(destination)
|
||||||
|
|
||||||
|
return this.commandWrapper.runCommand()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,5 +6,5 @@ export * from './object.model'
|
||||||
export * from './playlist-element-object'
|
export * from './playlist-element-object'
|
||||||
export * from './playlist-object'
|
export * from './playlist-object'
|
||||||
export * from './video-comment-object'
|
export * from './video-comment-object'
|
||||||
export * from './video-torrent-object'
|
export * from './video-object'
|
||||||
export * from './watch-action-object'
|
export * from './watch-action-object'
|
||||||
|
|
|
@ -51,6 +51,22 @@ export interface VideoObject {
|
||||||
|
|
||||||
attributedTo: ActivityPubAttributedTo[]
|
attributedTo: ActivityPubAttributedTo[]
|
||||||
|
|
||||||
|
preview?: ActivityPubStoryboard[]
|
||||||
|
|
||||||
to?: string[]
|
to?: string[]
|
||||||
cc?: string[]
|
cc?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityPubStoryboard {
|
||||||
|
type: 'Image'
|
||||||
|
rel: [ 'storyboard' ]
|
||||||
|
url: {
|
||||||
|
href: string
|
||||||
|
mediaType: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
tileWidth: number
|
||||||
|
tileHeight: number
|
||||||
|
tileDuration: string
|
||||||
|
}[]
|
||||||
|
}
|
|
@ -78,6 +78,10 @@ export interface CustomConfig {
|
||||||
torrents: {
|
torrents: {
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
storyboards: {
|
||||||
|
size: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signup: {
|
signup: {
|
||||||
|
|
|
@ -30,6 +30,7 @@ export type JobType =
|
||||||
| 'video-studio-edition'
|
| 'video-studio-edition'
|
||||||
| 'video-transcoding'
|
| 'video-transcoding'
|
||||||
| 'videos-views-stats'
|
| 'videos-views-stats'
|
||||||
|
| 'generate-video-storyboard'
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
id: number | string
|
id: number | string
|
||||||
|
@ -294,3 +295,10 @@ export interface TranscodingJobBuilderPayload {
|
||||||
priority?: number
|
priority?: number
|
||||||
}[][]
|
}[][]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface GenerateStoryboardPayload {
|
||||||
|
videoUUID: string
|
||||||
|
federate: boolean
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ export * from './channel-sync'
|
||||||
|
|
||||||
export * from './nsfw-policy.type'
|
export * from './nsfw-policy.type'
|
||||||
|
|
||||||
|
export * from './storyboard.model'
|
||||||
export * from './thumbnail.type'
|
export * from './thumbnail.type'
|
||||||
|
|
||||||
export * from './video-constant.model'
|
export * from './video-constant.model'
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface Storyboard {
|
||||||
|
storyboardPath: string
|
||||||
|
|
||||||
|
totalHeight: number
|
||||||
|
totalWidth: number
|
||||||
|
|
||||||
|
spriteHeight: number
|
||||||
|
spriteWidth: number
|
||||||
|
|
||||||
|
spriteDuration: number
|
||||||
|
}
|
|
@ -159,6 +159,10 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
newConfig: {
|
newConfig: {
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
|
allowAudioFiles: true,
|
||||||
|
allowAdditionalExtensions: true,
|
||||||
|
|
||||||
resolutions: {
|
resolutions: {
|
||||||
...ConfigCommand.getCustomConfigResolutions(false),
|
...ConfigCommand.getCustomConfigResolutions(false),
|
||||||
|
|
||||||
|
@ -368,6 +372,9 @@ export class ConfigCommand extends AbstractCommand {
|
||||||
},
|
},
|
||||||
torrents: {
|
torrents: {
|
||||||
size: 4
|
size: 4
|
||||||
|
},
|
||||||
|
storyboards: {
|
||||||
|
size: 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signup: {
|
signup: {
|
||||||
|
|
|
@ -33,6 +33,8 @@ async function waitJobs (
|
||||||
|
|
||||||
// Check if each server has pending request
|
// Check if each server has pending request
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
|
if (process.env.DEBUG) console.log('Checking ' + server.url)
|
||||||
|
|
||||||
for (const state of states) {
|
for (const state of states) {
|
||||||
|
|
||||||
const jobPromise = server.jobs.list({
|
const jobPromise = server.jobs.list({
|
||||||
|
@ -45,6 +47,10 @@ async function waitJobs (
|
||||||
.then(jobs => {
|
.then(jobs => {
|
||||||
if (jobs.length !== 0) {
|
if (jobs.length !== 0) {
|
||||||
pendingRequests = true
|
pendingRequests = true
|
||||||
|
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.log(jobs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -55,6 +61,10 @@ async function waitJobs (
|
||||||
.then(obj => {
|
.then(obj => {
|
||||||
if (obj.activityPubMessagesWaiting !== 0) {
|
if (obj.activityPubMessagesWaiting !== 0) {
|
||||||
pendingRequests = true
|
pendingRequests = true
|
||||||
|
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
tasks.push(debugPromise)
|
tasks.push(debugPromise)
|
||||||
|
@ -65,12 +75,15 @@ async function waitJobs (
|
||||||
for (const job of data) {
|
for (const job of data) {
|
||||||
if (job.state.id !== RunnerJobState.COMPLETED) {
|
if (job.state.id !== RunnerJobState.COMPLETED) {
|
||||||
pendingRequests = true
|
pendingRequests = true
|
||||||
|
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.log(job)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
tasks.push(runnerJobsPromise)
|
tasks.push(runnerJobsPromise)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks
|
return tasks
|
||||||
|
|
|
@ -35,6 +35,7 @@ import {
|
||||||
VideoPasswordsCommand,
|
VideoPasswordsCommand,
|
||||||
PlaylistsCommand,
|
PlaylistsCommand,
|
||||||
ServicesCommand,
|
ServicesCommand,
|
||||||
|
StoryboardCommand,
|
||||||
StreamingPlaylistsCommand,
|
StreamingPlaylistsCommand,
|
||||||
VideosCommand,
|
VideosCommand,
|
||||||
VideoStudioCommand,
|
VideoStudioCommand,
|
||||||
|
@ -149,6 +150,8 @@ export class PeerTubeServer {
|
||||||
registrations?: RegistrationsCommand
|
registrations?: RegistrationsCommand
|
||||||
videoPasswords?: VideoPasswordsCommand
|
videoPasswords?: VideoPasswordsCommand
|
||||||
|
|
||||||
|
storyboard?: StoryboardCommand
|
||||||
|
|
||||||
runners?: RunnersCommand
|
runners?: RunnersCommand
|
||||||
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
|
runnerRegistrationTokens?: RunnerRegistrationTokensCommand
|
||||||
runnerJobs?: RunnerJobsCommand
|
runnerJobs?: RunnerJobsCommand
|
||||||
|
@ -436,6 +439,8 @@ export class PeerTubeServer {
|
||||||
this.videoToken = new VideoTokenCommand(this)
|
this.videoToken = new VideoTokenCommand(this)
|
||||||
this.registrations = new RegistrationsCommand(this)
|
this.registrations = new RegistrationsCommand(this)
|
||||||
|
|
||||||
|
this.storyboard = new StoryboardCommand(this)
|
||||||
|
|
||||||
this.runners = new RunnersCommand(this)
|
this.runners = new RunnersCommand(this)
|
||||||
this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
|
this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
|
||||||
this.runnerJobs = new RunnerJobsCommand(this)
|
this.runnerJobs = new RunnerJobsCommand(this)
|
||||||
|
|
|
@ -11,6 +11,7 @@ export * from './live-command'
|
||||||
export * from './live'
|
export * from './live'
|
||||||
export * from './playlists-command'
|
export * from './playlists-command'
|
||||||
export * from './services-command'
|
export * from './services-command'
|
||||||
|
export * from './storyboard-command'
|
||||||
export * from './streaming-playlists-command'
|
export * from './streaming-playlists-command'
|
||||||
export * from './comments-command'
|
export * from './comments-command'
|
||||||
export * from './video-studio-command'
|
export * from './video-studio-command'
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { HttpStatusCode, Storyboard } from '@shared/models'
|
||||||
|
import { AbstractCommand, OverrideCommandOptions } from '../shared'
|
||||||
|
|
||||||
|
export class StoryboardCommand extends AbstractCommand {
|
||||||
|
|
||||||
|
list (options: OverrideCommandOptions & {
|
||||||
|
id: number | string
|
||||||
|
}) {
|
||||||
|
const path = '/api/v1/videos/' + options.id + '/storyboards'
|
||||||
|
|
||||||
|
return this.getRequestBody<{ storyboards: Storyboard[] }>({
|
||||||
|
...options,
|
||||||
|
|
||||||
|
path,
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -3668,6 +3668,27 @@ paths:
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/VideoBlacklist'
|
$ref: '#/components/schemas/VideoBlacklist'
|
||||||
|
|
||||||
|
/api/v1/videos/{id}/storyboards:
|
||||||
|
get:
|
||||||
|
summary: List storyboards of a video
|
||||||
|
operationId: listVideoStoryboards
|
||||||
|
tags:
|
||||||
|
- Video
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/idOrUUID'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
storyboards:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Storyboard'
|
||||||
|
|
||||||
/api/v1/videos/{id}/captions:
|
/api/v1/videos/{id}/captions:
|
||||||
get:
|
get:
|
||||||
summary: List captions of a video
|
summary: List captions of a video
|
||||||
|
@ -7509,6 +7530,20 @@ components:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/VideoCommentThreadTree'
|
$ref: '#/components/schemas/VideoCommentThreadTree'
|
||||||
|
Storyboard:
|
||||||
|
properties:
|
||||||
|
storyboardPath:
|
||||||
|
type: string
|
||||||
|
totalHeight:
|
||||||
|
type: integer
|
||||||
|
totalWidth:
|
||||||
|
type: integer
|
||||||
|
spriteHeight:
|
||||||
|
type: integer
|
||||||
|
spriteWidth:
|
||||||
|
type: integer
|
||||||
|
spriteDuration:
|
||||||
|
type: integer
|
||||||
VideoCaption:
|
VideoCaption:
|
||||||
properties:
|
properties:
|
||||||
language:
|
language:
|
||||||
|
|
Loading…
Reference in New Issue