From d8f39b126d9fe4bec1c12fb213548cc6edc87867 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 1 Jun 2023 14:51:16 +0200 Subject: [PATCH] Add storyboard support --- ...edit-advanced-configuration.component.html | 14 ++ .../edit-advanced-configuration.component.ts | 2 +- .../edit-custom-config.component.ts | 12 +- .../+video-watch/video-watch.component.ts | 45 ++++- .../custom-config-validators.ts | 17 +- .../shared/shared-main/video/video.service.ts | 24 +++ .../assets/player/peertube-player-manager.ts | 11 ++ .../assets/player/shared/control-bar/index.ts | 1 + .../shared/control-bar/storyboard-plugin.ts | 184 ++++++++++++++++++ .../assets/player/types/manager-options.ts | 3 +- .../player/types/peertube-videojs-typings.ts | 18 ++ client/src/sass/player/control-bar.scss | 15 ++ client/src/sass/player/mobile.scss | 25 +++ config/default.yaml | 3 + config/production.yaml.example | 3 + config/test-1.yaml | 1 + config/test-2.yaml | 1 + config/test-3.yaml | 1 + config/test-4.yaml | 1 + config/test-5.yaml | 1 + config/test-6.yaml | 1 + config/test.yaml | 2 + server.ts | 3 +- server/controllers/activitypub/client.ts | 10 +- server/controllers/api/config.ts | 3 + server/controllers/api/videos/index.ts | 2 + server/controllers/api/videos/storyboard.ts | 29 +++ server/controllers/api/videos/upload.ts | 9 + server/controllers/lazy-static.ts | 15 +- .../custom-validators/activitypub/videos.ts | 39 +++- server/initializers/checker-before-init.ts | 3 +- server/initializers/config.ts | 4 + server/initializers/constants.ts | 19 +- server/initializers/database.ts | 4 +- server/lib/activitypub/context.ts | 13 ++ server/lib/activitypub/send/send-update.ts | 14 +- server/lib/activitypub/videos/federate.ts | 13 +- .../videos/shared/abstract-builder.ts | 12 ++ .../lib/activitypub/videos/shared/creator.ts | 1 + .../shared/object-to-model-attributes.ts | 26 ++- server/lib/activitypub/videos/updater.ts | 5 + server/lib/files-cache/index.ts | 3 +- .../files-cache/videos-storyboard-cache.ts | 53 +++++ .../job-queue/handlers/generate-storyboard.ts | 138 +++++++++++++ server/lib/job-queue/handlers/video-import.ts | 9 + .../job-queue/handlers/video-live-ending.ts | 20 +- server/lib/job-queue/job-queue.ts | 11 +- server/lib/redis.ts | 4 +- server/lib/transcoding/web-transcoding.ts | 18 +- server/middlewares/validators/config.ts | 1 + .../video/formatter/video-format-utils.ts | 60 ++++-- server/models/video/storyboard.ts | 169 ++++++++++++++++ server/models/video/video-caption.ts | 8 +- server/models/video/video.ts | 45 ++++- server/tests/api/check-params/config.ts | 3 + server/tests/api/check-params/index.ts | 1 + .../api/check-params/video-storyboards.ts | 45 +++++ .../api/check-params/videos-overviews.ts | 2 +- server/tests/api/server/config.ts | 5 + server/tests/api/videos/index.ts | 1 + server/tests/api/videos/video-storyboard.ts | 184 ++++++++++++++++++ server/tests/fixtures/video_very_long_10p.mp4 | Bin 0 -> 185338 bytes server/types/models/video/index.ts | 1 + server/types/models/video/storyboard.ts | 15 ++ server/types/models/video/video-caption.ts | 2 +- server/types/models/video/video.ts | 8 +- shared/ffmpeg/ffmpeg-images.ts | 37 ++++ shared/models/activitypub/objects/index.ts | 2 +- ...ideo-torrent-object.ts => video-object.ts} | 16 ++ shared/models/server/custom-config.model.ts | 4 + shared/models/server/job.model.ts | 8 + shared/models/videos/index.ts | 1 + shared/models/videos/storyboard.model.ts | 11 ++ .../server-commands/server/config-command.ts | 7 + shared/server-commands/server/jobs.ts | 15 +- shared/server-commands/server/server.ts | 5 + shared/server-commands/videos/index.ts | 1 + .../videos/storyboard-command.ts | 19 ++ support/doc/api/openapi.yaml | 35 ++++ 79 files changed, 1476 insertions(+), 100 deletions(-) create mode 100644 client/src/assets/player/shared/control-bar/storyboard-plugin.ts create mode 100644 server/controllers/api/videos/storyboard.ts create mode 100644 server/lib/files-cache/videos-storyboard-cache.ts create mode 100644 server/lib/job-queue/handlers/generate-storyboard.ts create mode 100644 server/models/video/storyboard.ts create mode 100644 server/tests/api/check-params/video-storyboards.ts create mode 100644 server/tests/api/videos/video-storyboard.ts create mode 100644 server/tests/fixtures/video_very_long_10p.mp4 create mode 100644 server/types/models/video/storyboard.ts rename shared/models/activitypub/objects/{video-torrent-object.ts => video-object.ts} (79%) create mode 100644 shared/models/videos/storyboard.model.ts create mode 100644 shared/server-commands/videos/storyboard-command.ts diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html index bbf946df0..9701e7f85 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html @@ -52,6 +52,20 @@
{{ formErrors.cache.torrents.size }}
+ +
+ + +
+ + {getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}} +
+ +
{{ formErrors.cache.storyboards.size }}
+
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts index 79a98f288..06c5e6221 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts @@ -10,7 +10,7 @@ export class EditAdvancedConfigurationComponent { @Input() form: FormGroup @Input() formErrors: any - getCacheSize (type: 'captions' | 'previews' | 'torrents') { + getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') { return this.form.value['cache'][type]['size'] } } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 2c3b7560d..9219d608b 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -9,8 +9,7 @@ import { Notifier } from '@app/core' import { ServerService } from '@app/core/server/server.service' import { ADMIN_EMAIL_VALIDATOR, - CACHE_CAPTIONS_SIZE_VALIDATOR, - CACHE_PREVIEWS_SIZE_VALIDATOR, + CACHE_SIZE_VALIDATOR, CONCURRENCY_VALIDATOR, INDEX_URL_VALIDATOR, INSTANCE_NAME_VALIDATOR, @@ -120,13 +119,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { }, cache: { previews: { - size: CACHE_PREVIEWS_SIZE_VALIDATOR + size: CACHE_SIZE_VALIDATOR }, captions: { - size: CACHE_CAPTIONS_SIZE_VALIDATOR + size: CACHE_SIZE_VALIDATOR }, torrents: { - size: CACHE_CAPTIONS_SIZE_VALIDATOR + size: CACHE_SIZE_VALIDATOR + }, + storyboards: { + size: CACHE_SIZE_VALIDATOR } }, signup: { diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index aba3ee086..43744789d 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -33,6 +33,7 @@ import { LiveVideo, PeerTubeProblemDocument, ServerErrorCode, + Storyboard, VideoCaption, VideoPrivacy, VideoState @@ -69,6 +70,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoCaptions: VideoCaption[] = [] liveVideo: LiveVideo videoPassword: string + storyboards: Storyboard[] = [] playlistPosition: number playlist: VideoPlaylist = null @@ -285,9 +287,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { forkJoin([ videoAndLiveObs, this.videoCaptionService.listCaptions(videoId, videoPassword), + this.videoService.getStoryboards(videoId), this.userService.getAnonymousOrLoggedUser() ]).subscribe({ - next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { + next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => { const queryParams = this.route.snapshot.queryParams const urlOptions = { @@ -309,6 +312,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video, live, videoCaptions: captionsResult.data, + storyboards, videoFileToken, videoPassword, loggedInOrAnonymousUser, @@ -414,6 +418,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails live: LiveVideo videoCaptions: VideoCaption[] + storyboards: Storyboard[] videoFileToken: string videoPassword: string @@ -421,7 +426,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy { loggedInOrAnonymousUser: User 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) @@ -430,6 +445,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { this.liveVideo = live this.videoFileToken = videoFileToken this.videoPassword = videoPassword + this.storyboards = storyboards // Re init attributes this.playerPlaceholderImgSrc = undefined @@ -485,6 +501,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { const params = { video: this.video, videoCaptions: this.videoCaptions, + storyboards: this.storyboards, liveVideo: this.liveVideo, videoFileToken: this.videoFileToken, videoPassword: this.videoPassword, @@ -636,6 +653,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { video: VideoDetails liveVideo: LiveVideo videoCaptions: VideoCaption[] + storyboards: Storyboard[] videoFileToken: string videoPassword: string @@ -646,7 +664,17 @@ export class VideoWatchComponent implements OnInit, OnDestroy { forceAutoplay: boolean 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 byUrl = urlOptions.startTime !== undefined @@ -673,6 +701,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy { 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 ? { latencyMode: liveVideo.latencyMode } : undefined @@ -734,6 +771,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { videoPassword: () => videoPassword, videoCaptions: playerCaptions, + storyboard, videoShortUUID: video.shortUUID, videoUUID: video.uuid, @@ -767,6 +805,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { 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 if (typeof TextEncoder === 'undefined') { mode = 'webtorrent' diff --git a/client/src/app/shared/form-validators/custom-config-validators.ts b/client/src/app/shared/form-validators/custom-config-validators.ts index ff0813f7d..3672e5610 100644 --- a/client/src/app/shared/form-validators/custom-config-validators.ts +++ b/client/src/app/shared/form-validators/custom-config-validators.ts @@ -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]+') ], MESSAGES: { - required: $localize`Previews cache size is required.`, - min: $localize`Previews cache size must be greater than 1.`, - pattern: $localize`Previews 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.` + required: $localize`Cache size is required.`, + min: $localize`Cache size must be greater than 1.`, + pattern: $localize`Cache size must be a number.` } } diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index d67a2e192..c2e3d7511 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -11,6 +11,7 @@ import { FeedFormat, NSFWPolicyType, ResultList, + Storyboard, UserVideoRate, UserVideoRateType, 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) { return this.authHttp .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source') @@ -358,6 +378,8 @@ export class VideoService { ) } + // --------------------------------------------------------------------------- + setVideoLike (id: string, videoPassword: string) { return this.setVideoRate(id, 'like', videoPassword) } @@ -370,6 +392,8 @@ export class VideoService { return this.setVideoRate(id, 'none', videoPassword) } + // --------------------------------------------------------------------------- + getUserVideoRating (id: string) { const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts index 2781850b9..66d9c7298 100644 --- a/client/src/assets/player/peertube-player-manager.ts +++ b/client/src/assets/player/peertube-player-manager.ts @@ -6,6 +6,7 @@ import './shared/stats/stats-plugin' import './shared/bezels/bezels-plugin' import './shared/peertube/peertube-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/p2p-info-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) 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 { private static playerElementClassName: string private static playerElementAttributes: { name: string, value: string }[] = [] @@ -135,6 +142,10 @@ export class PeertubePlayerManager { p2pEnabled: options.common.p2pEnabled }) + if (options.common.storyboard) { + player.storyboard(options.common.storyboard) + } + player.on('p2pInfo', (_, data: PlayerNetworkInfo) => { if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts index e71e90713..24877c267 100644 --- a/client/src/assets/player/shared/control-bar/index.ts +++ b/client/src/assets/player/shared/control-bar/index.ts @@ -3,4 +3,5 @@ export * from './p2p-info-button' export * from './peertube-link-button' export * from './peertube-live-display' export * from './peertube-load-progress-bar' +export * from './storyboard-plugin' export * from './theater-button' diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts new file mode 100644 index 000000000..c1843f595 --- /dev/null +++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts @@ -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 } diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts index 1f3a0aa2e..a73341b4c 100644 --- a/client/src/assets/player/types/manager-options.ts +++ b/client/src/assets/player/types/manager-options.ts @@ -1,6 +1,6 @@ import { PluginsManager } from '@root-helpers/plugins-manager' 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' @@ -78,6 +78,7 @@ export interface CommonOptions extends CustomizationOptions { language?: string videoCaptions: VideoJSCaption[] + storyboard: VideoJSStoryboard videoUUID: string videoShortUUID: string diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts index 723c42c5d..30d2b287f 100644 --- a/client/src/assets/player/types/peertube-videojs-typings.ts +++ b/client/src/assets/player/types/peertube-videojs-typings.ts @@ -49,6 +49,8 @@ declare module 'video.js' { stats (options?: StatsCardOptions): StatsForNerdsPlugin + storyboard (options: StoryboardOptions): void + textTracks (): TextTrackList & { tracks_: (TextTrack & { id: string, label: string, src: string })[] } @@ -89,6 +91,13 @@ type VideoJSCaption = { src: string } +type VideoJSStoryboard = { + url: string + width: number + height: number + interval: number +} + type PeerTubePluginOptions = { mode: PlayerMode @@ -118,6 +127,13 @@ type MetricsPluginOptions = { videoUUID: string } +type StoryboardOptions = { + url: string + width: number + height: number + interval: number +} + type PlaylistPluginOptions = { elements: VideoPlaylistElement[] @@ -238,6 +254,7 @@ type PlaylistItemOptions = { export { PlayerNetworkInfo, + VideoJSStoryboard, PlaylistItemOptions, NextPreviousVideoButtonOptions, ResolutionUpdateData, @@ -251,6 +268,7 @@ export { PeerTubeResolution, VideoJSPluginOptions, LoadedQualityData, + StoryboardOptions, PeerTubeLinkButtonOptions, PeerTubeP2PInfoButtonOptions } diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss index 96b3adf66..02d5fa169 100644 --- a/client/src/sass/player/control-bar.scss +++ b/client/src/sass/player/control-bar.scss @@ -3,6 +3,20 @@ @use '_mixins' 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 { z-index: 100; @@ -79,6 +93,7 @@ top: -0.3em; } + // Only used on mobile .vjs-time-tooltip { display: none; } diff --git a/client/src/sass/player/mobile.scss b/client/src/sass/player/mobile.scss index 84d7a00f1..d150c54ee 100644 --- a/client/src/sass/player/mobile.scss +++ b/client/src/sass/player/mobile.scss @@ -6,6 +6,31 @@ /* Special mobile style */ .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-progress-control .vjs-slider .vjs-play-progress { // Always display the circle on mobile diff --git a/config/default.yaml b/config/default.yaml index 5d0eab4f5..e54c93ac5 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -136,6 +136,7 @@ storage: logs: 'storage/logs/' previews: 'storage/previews/' thumbnails: 'storage/thumbnails/' + storyboards: 'storage/storyboards/' torrents: 'storage/torrents/' captions: 'storage/captions/' cache: 'storage/cache/' @@ -396,6 +397,8 @@ cache: size: 500 # Max number of video captions/subtitles you want to cache torrents: size: 500 # Max number of video torrents you want to cache + storyboards: + size: 500 # Max number of video storyboards you want to cache admin: # Used to generate the root user at first startup diff --git a/config/production.yaml.example b/config/production.yaml.example index 5514f1af6..83ee48dae 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -134,6 +134,7 @@ storage: logs: '/var/www/peertube/storage/logs/' previews: '/var/www/peertube/storage/previews/' thumbnails: '/var/www/peertube/storage/thumbnails/' + storyboards: '/var/www/peertube/storage/storyboards/' torrents: '/var/www/peertube/storage/torrents/' captions: '/var/www/peertube/storage/captions/' cache: '/var/www/peertube/storage/cache/' @@ -406,6 +407,8 @@ cache: size: 500 # Max number of video captions/subtitles you want to cache torrents: size: 500 # Max number of video torrents you want to cache + storyboards: + size: 500 # Max number of video storyboards you want to cache admin: # Used to generate the root user at first startup diff --git a/config/test-1.yaml b/config/test-1.yaml index 7b62e3d0c..45ec27e63 100644 --- a/config/test-1.yaml +++ b/config/test-1.yaml @@ -19,6 +19,7 @@ storage: logs: 'test1/logs/' previews: 'test1/previews/' thumbnails: 'test1/thumbnails/' + storyboards: 'test1/storyboards/' torrents: 'test1/torrents/' captions: 'test1/captions/' cache: 'test1/cache/' diff --git a/config/test-2.yaml b/config/test-2.yaml index ba36369a6..7a06e5650 100644 --- a/config/test-2.yaml +++ b/config/test-2.yaml @@ -19,6 +19,7 @@ storage: logs: 'test2/logs/' previews: 'test2/previews/' thumbnails: 'test2/thumbnails/' + storyboards: 'test2/storyboards/' torrents: 'test2/torrents/' captions: 'test2/captions/' cache: 'test2/cache/' diff --git a/config/test-3.yaml b/config/test-3.yaml index 6adec7953..4b1563369 100644 --- a/config/test-3.yaml +++ b/config/test-3.yaml @@ -19,6 +19,7 @@ storage: logs: 'test3/logs/' previews: 'test3/previews/' thumbnails: 'test3/thumbnails/' + storyboards: 'test3/storyboards/' torrents: 'test3/torrents/' captions: 'test3/captions/' cache: 'test3/cache/' diff --git a/config/test-4.yaml b/config/test-4.yaml index f042aee46..248db4db9 100644 --- a/config/test-4.yaml +++ b/config/test-4.yaml @@ -19,6 +19,7 @@ storage: logs: 'test4/logs/' previews: 'test4/previews/' thumbnails: 'test4/thumbnails/' + storyboards: 'test4/storyboards/' torrents: 'test4/torrents/' captions: 'test4/captions/' cache: 'test4/cache/' diff --git a/config/test-5.yaml b/config/test-5.yaml index ad90fec04..04e2cd78d 100644 --- a/config/test-5.yaml +++ b/config/test-5.yaml @@ -19,6 +19,7 @@ storage: logs: 'test5/logs/' previews: 'test5/previews/' thumbnails: 'test5/thumbnails/' + storyboards: 'test5/storyboards/' torrents: 'test5/torrents/' captions: 'test5/captions/' cache: 'test5/cache/' diff --git a/config/test-6.yaml b/config/test-6.yaml index a579f1f01..25efe0054 100644 --- a/config/test-6.yaml +++ b/config/test-6.yaml @@ -19,6 +19,7 @@ storage: logs: 'test6/logs/' previews: 'test6/previews/' thumbnails: 'test6/thumbnails/' + storyboards: 'test6/storyboards/' torrents: 'test6/torrents/' captions: 'test6/captions/' cache: 'test6/cache/' diff --git a/config/test.yaml b/config/test.yaml index 361064af1..41ec0917e 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -73,6 +73,8 @@ cache: size: 1 torrents: size: 1 + storyboards: + size: 1 signup: enabled: true diff --git a/server.ts b/server.ts index a7a723b24..5d3acb2cd 100644 --- a/server.ts +++ b/server.ts @@ -101,7 +101,7 @@ loadLanguages() import { installApplication } from './server/initializers/installer' import { Emailer } from './server/lib/emailer' 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 { activityPubRouter, apiRouter, @@ -316,6 +316,7 @@ async function startApplication () { 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) 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 ActorFollowScheduler.Instance.enable() diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 750e3091c..166fc2a22 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -33,7 +33,6 @@ import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from ' import { AccountModel } from '../../models/account/account' import { AccountVideoRateModel } from '../../models/account/account-video-rate' import { ActorFollowModel } from '../../models/actor/actor-follow' -import { VideoCaptionModel } from '../../models/video/video-caption' import { VideoCommentModel } from '../../models/video/video-comment' import { VideoPlaylistModel } from '../../models/video/video-playlist' 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 // We need captions to render AP object - const captions = await VideoCaptionModel.listVideoCaptions(video.id) - const videoWithCaptions = Object.assign(video, { VideoCaptions: captions }) + const videoAP = await video.lightAPToFullAP(undefined) - const audience = getAudience(videoWithCaptions.VideoChannel.Account.Actor, videoWithCaptions.privacy === VideoPrivacy.PUBLIC) - const videoObject = audiencify(await videoWithCaptions.toActivityPubObject(), audience) + const audience = getAudience(videoAP.VideoChannel.Account.Actor, videoAP.privacy === VideoPrivacy.PUBLIC) + const videoObject = audiencify(await videoAP.toActivityPubObject(), audience) 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) } diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 228eae109..c1f6756de 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -190,6 +190,9 @@ function customConfig (): CustomConfig { }, torrents: { size: CONFIG.CACHE.TORRENTS.SIZE + }, + storyboards: { + size: CONFIG.CACHE.STORYBOARDS.SIZE } }, signup: { diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index d0eecf812..bbdda5b29 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -41,6 +41,7 @@ import { liveRouter } from './live' import { ownershipVideoRouter } from './ownership' import { rateVideoRouter } from './rate' import { statsRouter } from './stats' +import { storyboardRouter } from './storyboard' import { studioRouter } from './studio' import { tokenRouter } from './token' import { transcodingRouter } from './transcoding' @@ -70,6 +71,7 @@ videosRouter.use('/', filesRouter) videosRouter.use('/', transcodingRouter) videosRouter.use('/', tokenRouter) videosRouter.use('/', videoPasswordRouter) +videosRouter.use('/', storyboardRouter) videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), diff --git a/server/controllers/api/videos/storyboard.ts b/server/controllers/api/videos/storyboard.ts new file mode 100644 index 000000000..47a22011d --- /dev/null +++ b/server/controllers/api/videos/storyboard.ts @@ -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()) + }) +} diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts index 073eb480f..86ab4591e 100644 --- a/server/controllers/api/videos/upload.ts +++ b/server/controllers/api/videos/upload.ts @@ -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', payload: { diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts index b082e41f6..6ffd39730 100644 --- a/server/controllers/lazy-static.ts +++ b/server/controllers/lazy-static.ts @@ -5,7 +5,7 @@ import { MActorImage } from '@server/types/models' import { HttpStatusCode } from '../../shared/models/http/http-error-codes' import { logger } from '../helpers/logger' 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 { asyncMiddleware, handleStaticError } from '../middlewares' import { ActorImageModel } from '../models/actor/actor-image' @@ -32,6 +32,12 @@ lazyStaticRouter.use( handleStaticError ) +lazyStaticRouter.use( + LAZY_STATIC_PATHS.STORYBOARDS + ':filename', + asyncMiddleware(getStoryboard), + handleStaticError +) + lazyStaticRouter.use( LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename', 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 }) } +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) { const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename) if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end() diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts index 97b3577af..573a29754 100644 --- a/server/helpers/custom-validators/activitypub/videos.ts +++ b/server/helpers/custom-validators/activitypub/videos.ts @@ -1,6 +1,6 @@ import validator from 'validator' 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 { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants' import { peertubeTruncate } from '../../core-utils' @@ -48,6 +48,10 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { logger.debug('Video has invalid icons', { video }) return false } + if (!setValidStoryboard(video)) { + logger.debug('Video has invalid preview (storyboard)', { video }) + return false + } // Default attributes if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED @@ -201,3 +205,36 @@ function setRemoteVideoContent (video: any) { 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 +} diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 0a315ea70..939b73344 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -29,7 +29,8 @@ function checkMissedConfig () { 'video_channels.max_per_user', 'csp.enabled', 'csp.report_only', 'csp.report_uri', '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.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 'redundancy.videos.strategies', 'redundancy.videos.check_interval', diff --git a/server/initializers/config.ts b/server/initializers/config.ts index 51ac5d0ce..60ab6e204 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -112,6 +112,7 @@ const CONFIG = { STREAMING_PLAYLISTS_DIR: buildPath(config.get('storage.streaming_playlists')), REDUNDANCY_DIR: buildPath(config.get('storage.redundancy')), THUMBNAILS_DIR: buildPath(config.get('storage.thumbnails')), + STORYBOARDS_DIR: buildPath(config.get('storage.storyboards')), PREVIEWS_DIR: buildPath(config.get('storage.previews')), CAPTIONS_DIR: buildPath(config.get('storage.captions')), TORRENTS_DIR: buildPath(config.get('storage.torrents')), @@ -482,6 +483,9 @@ const CONFIG = { }, TORRENTS: { get SIZE () { return config.get('cache.torrents.size') } + }, + STORYBOARDS: { + get SIZE () { return config.get('cache.storyboards.size') } } }, INSTANCE: { diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index e2f34fe16..3a643a60b 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -174,6 +174,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = { 'after-video-channel-import': 1, 'move-to-object-storage': 3, 'transcoding-job-builder': 1, + 'generate-video-storyboard': 1, 'notify': 1, 'federate-video': 1 } @@ -198,6 +199,7 @@ const JOB_CONCURRENCY: { [id in Exclude 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[] = []) { const privacy = to.includes(ACTIVITY_PUB.PUBLIC) ? VideoPrivacy.PUBLIC @@ -228,6 +251,7 @@ export { getLiveAttributesFromObject, getCaptionAttributesFromObject, + getStoryboardAttributeFromObject, getVideoAttributesFromObject } diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts index 6ddd2301b..3a0886523 100644 --- a/server/lib/activitypub/videos/updater.ts +++ b/server/lib/activitypub/videos/updater.ts @@ -57,6 +57,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder { await Promise.all([ runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)), runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)), + runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)), this.setOrDeleteLive(videoUpdated), this.setPreview(videoUpdated) ]) @@ -138,6 +139,10 @@ export class APVideoUpdater extends APVideoAbstractBuilder { await this.insertOrReplaceCaptions(videoUpdated, t) } + private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) { + await this.insertOrReplaceStoryboard(videoUpdated, t) + } + private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) { if (!this.video.isLive) return diff --git a/server/lib/files-cache/index.ts b/server/lib/files-cache/index.ts index e5853f7d6..59cec7215 100644 --- a/server/lib/files-cache/index.ts +++ b/server/lib/files-cache/index.ts @@ -1,3 +1,4 @@ -export * from './videos-preview-cache' export * from './videos-caption-cache' +export * from './videos-preview-cache' +export * from './videos-storyboard-cache' export * from './videos-torrent-cache' diff --git a/server/lib/files-cache/videos-storyboard-cache.ts b/server/lib/files-cache/videos-storyboard-cache.ts new file mode 100644 index 000000000..b0a55104f --- /dev/null +++ b/server/lib/files-cache/videos-storyboard-cache.ts @@ -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 { + + 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 +} diff --git a/server/lib/job-queue/handlers/generate-storyboard.ts b/server/lib/job-queue/handlers/generate-storyboard.ts new file mode 100644 index 000000000..652cac272 --- /dev/null +++ b/server/lib/job-queue/handlers/generate-storyboard.ts @@ -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 { + 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 +} diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index cdd362f6e..c1355dcef 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -306,6 +306,15 @@ async function afterImportSuccess (options: { 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) { await JobQueue.Instance.createJob( await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT }) diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 49feb53f2..95d4f5e64 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -1,6 +1,8 @@ import { Job } from 'bullmq' import { readdir, remove } from 'fs-extra' 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 { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 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 { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' import { logger, loggerTagsFactory } from '../../../helpers/logger' -import { peertubeTruncate } from '@server/helpers/core-utils' -import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' +import { JobQueue } from '../job-queue' const lTags = loggerTagsFactory('live', 'job') @@ -147,6 +148,8 @@ async function saveReplayToExternalVideo (options: { } await moveToNextState({ video: replayVideo, isNewVideo: true }) + + await createStoryboardJob(replayVideo) } async function replaceLiveByReplay (options: { @@ -186,6 +189,7 @@ async function replaceLiveByReplay (options: { await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory }) + // FIXME: should not happen in this function if (permanentLive) { // Remove session replay await remove(replayDirectory) } 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 await moveToNextState({ video: videoWithFiles, isNewVideo: true }) + + await createStoryboardJob(videoWithFiles) } async function assignReplayFilesToVideo (options: { @@ -277,3 +283,13 @@ async function cleanupLiveAndFederate (options: { 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 + } + }) +} diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 03f6fbea7..177bca285 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -25,6 +25,7 @@ import { DeleteResumableUploadMetaFilePayload, EmailPayload, FederateVideoPayload, + GenerateStoryboardPayload, JobState, JobType, ManageVideoTorrentPayload, @@ -65,6 +66,7 @@ import { processVideoLiveEnding } from './handlers/video-live-ending' import { processVideoStudioEdition } from './handlers/video-studio-edition' import { processVideoTranscoding } from './handlers/video-transcoding' import { processVideosViewsStats } from './handlers/video-views-stats' +import { processGenerateStoryboard } from './handlers/generate-storyboard' export type CreateJobArgument = { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | @@ -91,7 +93,8 @@ export type CreateJobArgument = { type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } | { type: 'notify', payload: NotifyPayload } | { 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 = { delay?: number @@ -122,7 +125,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise } = { 'video-redundancy': processVideoRedundancy, 'video-studio-edition': processVideoStudioEdition, '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 } = { @@ -141,10 +145,11 @@ const jobTypes: JobType[] = [ 'after-video-channel-import', 'email', 'federate-video', - 'transcoding-job-builder', + 'generate-video-storyboard', 'manage-video-torrent', 'move-to-object-storage', 'notify', + 'transcoding-job-builder', 'video-channel-import', 'video-file-import', 'video-import', diff --git a/server/lib/redis.ts b/server/lib/redis.ts index 8430b2227..48d9986b5 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts @@ -325,8 +325,8 @@ class Redis { const value = await this.getValue('resumable-upload-' + uploadId) return value - ? JSON.parse(value) - : '' + ? JSON.parse(value) as { video: { id: number, shortUUID: string, uuid: string } } + : undefined } deleteUploadSession (uploadId: string) { diff --git a/server/lib/transcoding/web-transcoding.ts b/server/lib/transcoding/web-transcoding.ts index 7cc8f20bc..a499db422 100644 --- a/server/lib/transcoding/web-transcoding.ts +++ b/server/lib/transcoding/web-transcoding.ts @@ -9,6 +9,7 @@ import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVOD import { VideoResolution, VideoStorage } from '@shared/models' import { CONFIG } from '../../initializers/config' import { VideoFileModel } from '../../models/video/video-file' +import { JobQueue } from '../job-queue' import { generateWebTorrentVideoFilename } from '../paths' import { buildFileMetadata } from '../video-file' import { VideoPathManager } from '../video-path-manager' @@ -198,7 +199,8 @@ export async function mergeAudioVideofile (options: { return onWebTorrentVideoFileTranscoding({ video, videoFile: inputVideoFile, - videoOutputPath + videoOutputPath, + wasAudioFile: true }) }) @@ -212,8 +214,9 @@ export async function onWebTorrentVideoFileTranscoding (options: { video: MVideoFullLight videoFile: MVideoFile 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) @@ -242,6 +245,17 @@ export async function onWebTorrentVideoFileTranscoding (options: { await VideoFileModel.customUpsert(videoFile, 'video', undefined) 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 } } finally { mutexReleaser() diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index a0074cb24..7029a857f 100644 --- a/server/middlewares/validators/config.ts +++ b/server/middlewares/validators/config.ts @@ -25,6 +25,7 @@ const customConfigUpdateValidator = [ body('cache.previews.size').isInt(), body('cache.captions.size').isInt(), body('cache.torrents.size').isInt(), + body('cache.storyboards.size').isInt(), body('signup.enabled').isBoolean(), body('signup.limit').isInt(), diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts index f2001e432..4179545b8 100644 --- a/server/models/video/formatter/video-format-utils.ts +++ b/server/models/video/formatter/video-format-utils.ts @@ -5,6 +5,7 @@ import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' import { VideoViewsManager } from '@server/lib/views/video-views-manager' import { uuidToShort } from '@shared/extra-utils' import { + ActivityPubStoryboard, ActivityTagObject, ActivityUrlObject, Video, @@ -347,29 +348,17 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject { name: t.name })) - let language - if (video.language) { - language = { - identifier: video.language, - name: getLanguageLabel(video.language) - } - } + const language = video.language + ? { identifier: video.language, name: getLanguageLabel(video.language) } + : undefined - let category - if (video.category) { - category = { - identifier: video.category + '', - name: getCategoryLabel(video.category) - } - } + const category = video.category + ? { identifier: video.category + '', name: getCategoryLabel(video.category) } + : undefined - let licence - if (video.licence) { - licence = { - identifier: video.licence + '', - name: getLicenceLabel(video.licence) - } - } + const licence = video.licence + ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) } + : undefined const url: ActivityUrlObject[] = [ // 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 })), + preview: buildPreviewAPAttribute(video), + url, likes: getLocalVideoLikesActivityPubUrl(video), @@ -541,3 +532,30 @@ function buildLiveAPAttributes (video: MVideoAP) { 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) + } + ] + } + ] +} diff --git a/server/models/video/storyboard.ts b/server/models/video/storyboard.ts new file mode 100644 index 000000000..65a044c98 --- /dev/null +++ b/server/models/video/storyboard.ts @@ -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>> { + + @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 { + const query = { + where: { + videoId + }, + transaction + } + + return StoryboardModel.findOne(query) + } + + static loadByFilename (filename: string): Promise { + const query = { + where: { + filename + } + } + + return StoryboardModel.findOne(query) + } + + static loadWithVideoByFilename (filename: string): Promise { + const query = { + where: { + filename + }, + include: [ + { + model: VideoModel.unscoped(), + required: true + } + ] + } + + return StoryboardModel.findOne(query) + } + + // --------------------------------------------------------------------------- + + static async listStoryboardsOf (video: MVideo): Promise { + const query = { + where: { + videoId: video.id + } + } + + const storyboards = await StoryboardModel.findAll(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 + } + } +} diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts index 1fb1cae82..dd4cefd65 100644 --- a/server/models/video/video-caption.ts +++ b/server/models/video/video-caption.ts @@ -15,7 +15,7 @@ import { Table, UpdatedAt } 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 { AttributesOnly } from '@shared/typescript-utils' import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' @@ -225,7 +225,7 @@ export class VideoCaptionModel extends Model>> { }) VideoJobInfo: VideoJobInfoModel + @HasOne(() => StoryboardModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + Storyboard: StoryboardModel + @AfterCreate static notifyCreate (video: MVideo) { InternalEventEmitter.Instance.emit('video-created', { video }) @@ -903,6 +916,10 @@ export class VideoModel extends Model>> { model: VideoCaptionModel.unscoped(), required: false }, + { + model: StoryboardModel.unscoped(), + required: false + }, { attributes: [ 'id', 'url' ], model: VideoShareModel.unscoped(), @@ -1768,6 +1785,32 @@ export class VideoModel extends Model>> { ) } + async lightAPToFullAP (this: MVideoAPLight, transaction: Transaction): Promise { + 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 + } + + const getStoryboard = () => { + if (videoAP.Storyboard) return videoAP.Storyboard + + return this.$get('Storyboard', { transaction }) as Promise + } + + const [ captions, storyboard ] = await Promise.all([ getCaptions(), getStoryboard() ]) + + return Object.assign(this, { + VideoCaptions: captions, + Storyboard: storyboard + }) + } + getTruncatedDescription () { if (!this.description) return null diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts index 472cad182..3c752cc3e 100644 --- a/server/tests/api/check-params/config.ts +++ b/server/tests/api/check-params/config.ts @@ -74,6 +74,9 @@ describe('Test config API validators', function () { }, torrents: { size: 4 + }, + storyboards: { + size: 5 } }, signup: { diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 400d312d3..c2a7ccd78 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -34,6 +34,7 @@ import './video-comments' import './video-files' import './video-imports' import './video-playlists' +import './video-storyboards' import './video-source' import './video-studio' import './video-token' diff --git a/server/tests/api/check-params/video-storyboards.ts b/server/tests/api/check-params/video-storyboards.ts new file mode 100644 index 000000000..a43d8fc48 --- /dev/null +++ b/server/tests/api/check-params/video-storyboards.ts @@ -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 ]) + }) +}) diff --git a/server/tests/api/check-params/videos-overviews.ts b/server/tests/api/check-params/videos-overviews.ts index f9cdb7ab3..ae7de24dd 100644 --- a/server/tests/api/check-params/videos-overviews.ts +++ b/server/tests/api/check-params/videos-overviews.ts @@ -2,7 +2,7 @@ import { cleanupTests, createSingleServer, PeerTubeServer } from '@shared/server-commands' -describe('Test videos overview', function () { +describe('Test videos overview API validator', function () { let server: PeerTubeServer // --------------------------------------------------------------- diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index 011ba268c..efa7b50e3 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -46,6 +46,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { expect(data.cache.previews.size).to.equal(1) expect(data.cache.captions.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.limit).to.equal(4) @@ -154,6 +155,7 @@ function checkUpdatedConfig (data: CustomConfig) { expect(data.cache.previews.size).to.equal(2) expect(data.cache.captions.size).to.equal(3) 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.limit).to.equal(5) @@ -290,6 +292,9 @@ const newCustomConfig: CustomConfig = { }, torrents: { size: 4 + }, + storyboards: { + size: 5 } }, signup: { diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts index 357c08199..9c79b3aa6 100644 --- a/server/tests/api/videos/index.ts +++ b/server/tests/api/videos/index.ts @@ -20,3 +20,4 @@ import './videos-history' import './videos-overview' import './video-source' import './video-static-file-privacy' +import './video-storyboard' diff --git a/server/tests/api/videos/video-storyboard.ts b/server/tests/api/videos/video-storyboard.ts new file mode 100644 index 000000000..7ccdca8f7 --- /dev/null +++ b/server/tests/api/videos/video-storyboard.ts @@ -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) + }) +}) diff --git a/server/tests/fixtures/video_very_long_10p.mp4 b/server/tests/fixtures/video_very_long_10p.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..85229793345b499f63c4bde7de77a0c3d1301df6 GIT binary patch literal 185338 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�`jevbwyD+Ui9TQSXGMQb^cO{6DEiBy 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⁣?e(X~>%lVLB`Mfm0C$ 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 = PickWith + +// ############################################################################ + +export type MStoryboard = Omit + +// ############################################################################ + +export type MStoryboardVideo = + MStoryboard & + Use<'Video', MVideo> diff --git a/server/types/models/video/video-caption.ts b/server/types/models/video/video-caption.ts index 8cd801064..d3adec362 100644 --- a/server/types/models/video/video-caption.ts +++ b/server/types/models/video/video-caption.ts @@ -11,7 +11,7 @@ export type MVideoCaption = Omit // ############################################################################ export type MVideoCaptionLanguage = Pick -export type MVideoCaptionLanguageUrl = Pick +export type MVideoCaptionLanguageUrl = Pick export type MVideoCaptionVideo = MVideoCaption & diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts index 8021e56bb..53ee94269 100644 --- a/server/types/models/video/video.ts +++ b/server/types/models/video/video.ts @@ -3,6 +3,7 @@ import { VideoModel } from '../../../models/video/video' import { MTrackerUrl } from '../server/tracker' import { MUserVideoHistoryTime } from '../user/user-video-history' import { MScheduleVideoUpdate } from './schedule-video-update' +import { MStoryboard } from './storyboard' import { MTag } from './tag' import { MThumbnail } from './thumbnail' import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist' @@ -32,7 +33,7 @@ type Use = PickWith export type MVideo = Omit + 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard'> // ############################################################################ @@ -173,9 +174,10 @@ export type MVideoAP = Use<'VideoBlacklist', MVideoBlacklistUnfederated> & Use<'VideoFiles', MVideoFileRedundanciesOpt[]> & Use<'Thumbnails', MThumbnail[]> & - Use<'VideoLive', MVideoLive> + Use<'VideoLive', MVideoLive> & + Use<'Storyboard', MStoryboard> -export type MVideoAPWithoutCaption = Omit +export type MVideoAPLight = Omit export type MVideoDetails = MVideo & diff --git a/shared/ffmpeg/ffmpeg-images.ts b/shared/ffmpeg/ffmpeg-images.ts index 2db63bd8b..27305382c 100644 --- a/shared/ffmpeg/ffmpeg-images.ts +++ b/shared/ffmpeg/ffmpeg-images.ts @@ -56,4 +56,41 @@ export class FFmpegImage { .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() + } } diff --git a/shared/models/activitypub/objects/index.ts b/shared/models/activitypub/objects/index.ts index 9aa3c462c..a2e040b32 100644 --- a/shared/models/activitypub/objects/index.ts +++ b/shared/models/activitypub/objects/index.ts @@ -6,5 +6,5 @@ export * from './object.model' export * from './playlist-element-object' export * from './playlist-object' export * from './video-comment-object' -export * from './video-torrent-object' +export * from './video-object' export * from './watch-action-object' diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-object.ts similarity index 79% rename from shared/models/activitypub/objects/video-torrent-object.ts rename to shared/models/activitypub/objects/video-object.ts index 23d54bdbd..a252a2df0 100644 --- a/shared/models/activitypub/objects/video-torrent-object.ts +++ b/shared/models/activitypub/objects/video-object.ts @@ -51,6 +51,22 @@ export interface VideoObject { attributedTo: ActivityPubAttributedTo[] + preview?: ActivityPubStoryboard[] + to?: 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 + }[] +} diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts index 4202589f3..1012312f3 100644 --- a/shared/models/server/custom-config.model.ts +++ b/shared/models/server/custom-config.model.ts @@ -78,6 +78,10 @@ export interface CustomConfig { torrents: { size: number } + + storyboards: { + size: number + } } signup: { diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts index 22ecee324..9c40079fb 100644 --- a/shared/models/server/job.model.ts +++ b/shared/models/server/job.model.ts @@ -30,6 +30,7 @@ export type JobType = | 'video-studio-edition' | 'video-transcoding' | 'videos-views-stats' + | 'generate-video-storyboard' export interface Job { id: number | string @@ -294,3 +295,10 @@ export interface TranscodingJobBuilderPayload { priority?: number }[][] } + +// --------------------------------------------------------------------------- + +export interface GenerateStoryboardPayload { + videoUUID: string + federate: boolean +} diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 80be1854b..b3ce6ad3f 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts @@ -15,6 +15,7 @@ export * from './channel-sync' export * from './nsfw-policy.type' +export * from './storyboard.model' export * from './thumbnail.type' export * from './video-constant.model' diff --git a/shared/models/videos/storyboard.model.ts b/shared/models/videos/storyboard.model.ts new file mode 100644 index 000000000..c92c81f09 --- /dev/null +++ b/shared/models/videos/storyboard.model.ts @@ -0,0 +1,11 @@ +export interface Storyboard { + storyboardPath: string + + totalHeight: number + totalWidth: number + + spriteHeight: number + spriteWidth: number + + spriteDuration: number +} diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts index b94bd2625..114db8091 100644 --- a/shared/server-commands/server/config-command.ts +++ b/shared/server-commands/server/config-command.ts @@ -159,6 +159,10 @@ export class ConfigCommand extends AbstractCommand { newConfig: { transcoding: { enabled: true, + + allowAudioFiles: true, + allowAdditionalExtensions: true, + resolutions: { ...ConfigCommand.getCustomConfigResolutions(false), @@ -368,6 +372,9 @@ export class ConfigCommand extends AbstractCommand { }, torrents: { size: 4 + }, + storyboards: { + size: 5 } }, signup: { diff --git a/shared/server-commands/server/jobs.ts b/shared/server-commands/server/jobs.ts index ff3098063..8f131fba4 100644 --- a/shared/server-commands/server/jobs.ts +++ b/shared/server-commands/server/jobs.ts @@ -33,6 +33,8 @@ async function waitJobs ( // Check if each server has pending request for (const server of servers) { + if (process.env.DEBUG) console.log('Checking ' + server.url) + for (const state of states) { const jobPromise = server.jobs.list({ @@ -45,6 +47,10 @@ async function waitJobs ( .then(jobs => { if (jobs.length !== 0) { pendingRequests = true + + if (process.env.DEBUG) { + console.log(jobs) + } } }) @@ -55,6 +61,10 @@ async function waitJobs ( .then(obj => { if (obj.activityPubMessagesWaiting !== 0) { pendingRequests = true + + if (process.env.DEBUG) { + console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting) + } } }) tasks.push(debugPromise) @@ -65,12 +75,15 @@ async function waitJobs ( for (const job of data) { if (job.state.id !== RunnerJobState.COMPLETED) { pendingRequests = true + + if (process.env.DEBUG) { + console.log(job) + } } } }) tasks.push(runnerJobsPromise) } - } return tasks diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts index 0911e22b0..6aa4296b0 100644 --- a/shared/server-commands/server/server.ts +++ b/shared/server-commands/server/server.ts @@ -35,6 +35,7 @@ import { VideoPasswordsCommand, PlaylistsCommand, ServicesCommand, + StoryboardCommand, StreamingPlaylistsCommand, VideosCommand, VideoStudioCommand, @@ -149,6 +150,8 @@ export class PeerTubeServer { registrations?: RegistrationsCommand videoPasswords?: VideoPasswordsCommand + storyboard?: StoryboardCommand + runners?: RunnersCommand runnerRegistrationTokens?: RunnerRegistrationTokensCommand runnerJobs?: RunnerJobsCommand @@ -436,6 +439,8 @@ export class PeerTubeServer { this.videoToken = new VideoTokenCommand(this) this.registrations = new RegistrationsCommand(this) + this.storyboard = new StoryboardCommand(this) + this.runners = new RunnersCommand(this) this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) this.runnerJobs = new RunnerJobsCommand(this) diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts index da36b5b6b..106d80af0 100644 --- a/shared/server-commands/videos/index.ts +++ b/shared/server-commands/videos/index.ts @@ -11,6 +11,7 @@ export * from './live-command' export * from './live' export * from './playlists-command' export * from './services-command' +export * from './storyboard-command' export * from './streaming-playlists-command' export * from './comments-command' export * from './video-studio-command' diff --git a/shared/server-commands/videos/storyboard-command.ts b/shared/server-commands/videos/storyboard-command.ts new file mode 100644 index 000000000..06d90fc12 --- /dev/null +++ b/shared/server-commands/videos/storyboard-command.ts @@ -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 + }) + } +} diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index ff94f802b..cd0e6ffd8 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -3668,6 +3668,27 @@ paths: items: $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: get: summary: List captions of a video @@ -7509,6 +7530,20 @@ components: type: array items: $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: properties: language: