aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/job-queue
diff options
context:
space:
mode:
Diffstat (limited to 'server/lib/job-queue')
-rw-r--r--server/lib/job-queue/handlers/generate-storyboard.ts138
-rw-r--r--server/lib/job-queue/handlers/video-import.ts9
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts20
-rw-r--r--server/lib/job-queue/job-queue.ts11
4 files changed, 173 insertions, 5 deletions
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 @@
1import { Job } from 'bullmq'
2import { join } from 'path'
3import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
4import { generateImageFilename, getImageSize } from '@server/helpers/image-utils'
5import { logger, loggerTagsFactory } from '@server/helpers/logger'
6import { CONFIG } from '@server/initializers/config'
7import { STORYBOARD } from '@server/initializers/constants'
8import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
9import { VideoPathManager } from '@server/lib/video-path-manager'
10import { StoryboardModel } from '@server/models/video/storyboard'
11import { VideoModel } from '@server/models/video/video'
12import { MVideo } from '@server/types/models'
13import { FFmpegImage, isAudioFile } from '@shared/ffmpeg'
14import { GenerateStoryboardPayload } from '@shared/models'
15
16const lTagsBase = loggerTagsFactory('storyboard')
17
18async function processGenerateStoryboard (job: Job): Promise<void> {
19 const payload = job.data as GenerateStoryboardPayload
20 const lTags = lTagsBase(payload.videoUUID)
21
22 logger.info('Processing generate storyboard of %s in job %s.', payload.videoUUID, job.id, lTags)
23
24 const video = await VideoModel.loadFull(payload.videoUUID)
25 if (!video) {
26 logger.info('Video %s does not exist anymore, skipping storyboard generation.', payload.videoUUID, lTags)
27 return
28 }
29
30 const inputFile = video.getMaxQualityFile()
31
32 await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
33 const isAudio = await isAudioFile(videoPath)
34
35 if (isAudio) {
36 logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags)
37 return
38 }
39
40 const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail'))
41
42 const filename = generateImageFilename()
43 const destination = join(CONFIG.STORAGE.STORYBOARDS_DIR, filename)
44
45 const totalSprites = buildTotalSprites(video)
46 const spriteDuration = Math.round(video.duration / totalSprites)
47
48 const spritesCount = findGridSize({
49 toFind: totalSprites,
50 maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT
51 })
52
53 logger.debug(
54 'Generating storyboard from video of %s to %s', video.uuid, destination,
55 { ...lTags, spritesCount, spriteDuration, videoDuration: video.duration }
56 )
57
58 await ffmpeg.generateStoryboardFromVideo({
59 destination,
60 path: videoPath,
61 sprites: {
62 size: STORYBOARD.SPRITE_SIZE,
63 count: spritesCount,
64 duration: spriteDuration
65 }
66 })
67
68 const imageSize = await getImageSize(destination)
69
70 const existing = await StoryboardModel.loadByVideo(video.id)
71 if (existing) await existing.destroy()
72
73 await StoryboardModel.create({
74 filename,
75 totalHeight: imageSize.height,
76 totalWidth: imageSize.width,
77 spriteHeight: STORYBOARD.SPRITE_SIZE.height,
78 spriteWidth: STORYBOARD.SPRITE_SIZE.width,
79 spriteDuration,
80 videoId: video.id
81 })
82
83 logger.info('Storyboard generation %s ended for video %s.', destination, video.uuid, lTags)
84 })
85
86 if (payload.federate) {
87 await federateVideoIfNeeded(video, false)
88 }
89}
90
91// ---------------------------------------------------------------------------
92
93export {
94 processGenerateStoryboard
95}
96
97function buildTotalSprites (video: MVideo) {
98 const maxSprites = STORYBOARD.SPRITE_SIZE.height * STORYBOARD.SPRITE_SIZE.width
99 const totalSprites = Math.min(Math.ceil(video.duration), maxSprites)
100
101 // We can generate a single line
102 if (totalSprites <= STORYBOARD.SPRITES_MAX_EDGE_COUNT) return totalSprites
103
104 return findGridFit(totalSprites, STORYBOARD.SPRITES_MAX_EDGE_COUNT)
105}
106
107function findGridSize (options: {
108 toFind: number
109 maxEdgeCount: number
110}) {
111 const { toFind, maxEdgeCount } = options
112
113 for (let i = 1; i <= maxEdgeCount; i++) {
114 for (let j = i; j <= maxEdgeCount; j++) {
115 if (toFind === i * j) return { width: j, height: i }
116 }
117 }
118
119 throw new Error(`Could not find grid size (to find: ${toFind}, max edge count: ${maxEdgeCount}`)
120}
121
122function findGridFit (value: number, maxMultiplier: number) {
123 for (let i = value; i--; i > 0) {
124 if (!isPrimeWithin(i, maxMultiplier)) return i
125 }
126
127 throw new Error('Could not find prime number below ' + value)
128}
129
130function isPrimeWithin (value: number, maxMultiplier: number) {
131 if (value < 2) return false
132
133 for (let i = 2, end = Math.min(Math.sqrt(value), maxMultiplier); i <= end; i++) {
134 if (value % i === 0 && value / i <= maxMultiplier) return false
135 }
136
137 return true
138}
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: {
306 Notifier.Instance.notifyOnNewVideoIfNeeded(video) 306 Notifier.Instance.notifyOnNewVideoIfNeeded(video)
307 } 307 }
308 308
309 // Generate the storyboard in the job queue, and don't forget to federate an update after
310 await JobQueue.Instance.createJob({
311 type: 'generate-video-storyboard' as 'generate-video-storyboard',
312 payload: {
313 videoUUID: video.uuid,
314 federate: true
315 }
316 })
317
309 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { 318 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
310 await JobQueue.Instance.createJob( 319 await JobQueue.Instance.createJob(
311 await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT }) 320 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 @@
1import { Job } from 'bullmq' 1import { Job } from 'bullmq'
2import { readdir, remove } from 'fs-extra' 2import { readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { peertubeTruncate } from '@server/helpers/core-utils'
5import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
4import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
6import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' 8import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
@@ -20,8 +22,7 @@ import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@serv
20import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg' 22import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg'
21import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' 23import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
22import { logger, loggerTagsFactory } from '../../../helpers/logger' 24import { logger, loggerTagsFactory } from '../../../helpers/logger'
23import { peertubeTruncate } from '@server/helpers/core-utils' 25import { JobQueue } from '../job-queue'
24import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
25 26
26const lTags = loggerTagsFactory('live', 'job') 27const lTags = loggerTagsFactory('live', 'job')
27 28
@@ -147,6 +148,8 @@ async function saveReplayToExternalVideo (options: {
147 } 148 }
148 149
149 await moveToNextState({ video: replayVideo, isNewVideo: true }) 150 await moveToNextState({ video: replayVideo, isNewVideo: true })
151
152 await createStoryboardJob(replayVideo)
150} 153}
151 154
152async function replaceLiveByReplay (options: { 155async function replaceLiveByReplay (options: {
@@ -186,6 +189,7 @@ async function replaceLiveByReplay (options: {
186 189
187 await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory }) 190 await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
188 191
192 // FIXME: should not happen in this function
189 if (permanentLive) { // Remove session replay 193 if (permanentLive) { // Remove session replay
190 await remove(replayDirectory) 194 await remove(replayDirectory)
191 } else { // We won't stream again in this live, we can delete the base replay directory 195 } else { // We won't stream again in this live, we can delete the base replay directory
@@ -213,6 +217,8 @@ async function replaceLiveByReplay (options: {
213 217
214 // We consider this is a new video 218 // We consider this is a new video
215 await moveToNextState({ video: videoWithFiles, isNewVideo: true }) 219 await moveToNextState({ video: videoWithFiles, isNewVideo: true })
220
221 await createStoryboardJob(videoWithFiles)
216} 222}
217 223
218async function assignReplayFilesToVideo (options: { 224async function assignReplayFilesToVideo (options: {
@@ -277,3 +283,13 @@ async function cleanupLiveAndFederate (options: {
277 logger.warn('Cannot federate live after cleanup', { videoId: video.id, err }) 283 logger.warn('Cannot federate live after cleanup', { videoId: video.id, err })
278 } 284 }
279} 285}
286
287function createStoryboardJob (video: MVideo) {
288 return JobQueue.Instance.createJob({
289 type: 'generate-video-storyboard' as 'generate-video-storyboard',
290 payload: {
291 videoUUID: video.uuid,
292 federate: true
293 }
294 })
295}
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 {
25 DeleteResumableUploadMetaFilePayload, 25 DeleteResumableUploadMetaFilePayload,
26 EmailPayload, 26 EmailPayload,
27 FederateVideoPayload, 27 FederateVideoPayload,
28 GenerateStoryboardPayload,
28 JobState, 29 JobState,
29 JobType, 30 JobType,
30 ManageVideoTorrentPayload, 31 ManageVideoTorrentPayload,
@@ -65,6 +66,7 @@ import { processVideoLiveEnding } from './handlers/video-live-ending'
65import { processVideoStudioEdition } from './handlers/video-studio-edition' 66import { processVideoStudioEdition } from './handlers/video-studio-edition'
66import { processVideoTranscoding } from './handlers/video-transcoding' 67import { processVideoTranscoding } from './handlers/video-transcoding'
67import { processVideosViewsStats } from './handlers/video-views-stats' 68import { processVideosViewsStats } from './handlers/video-views-stats'
69import { processGenerateStoryboard } from './handlers/generate-storyboard'
68 70
69export type CreateJobArgument = 71export type CreateJobArgument =
70 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | 72 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -91,7 +93,8 @@ export type CreateJobArgument =
91 { type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } | 93 { type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } |
92 { type: 'notify', payload: NotifyPayload } | 94 { type: 'notify', payload: NotifyPayload } |
93 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } | 95 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
94 { type: 'federate-video', payload: FederateVideoPayload } 96 { type: 'federate-video', payload: FederateVideoPayload } |
97 { type: 'generate-video-storyboard', payload: GenerateStoryboardPayload }
95 98
96export type CreateJobOptions = { 99export type CreateJobOptions = {
97 delay?: number 100 delay?: number
@@ -122,7 +125,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
122 'video-redundancy': processVideoRedundancy, 125 'video-redundancy': processVideoRedundancy,
123 'video-studio-edition': processVideoStudioEdition, 126 'video-studio-edition': processVideoStudioEdition,
124 'video-transcoding': processVideoTranscoding, 127 'video-transcoding': processVideoTranscoding,
125 'videos-views-stats': processVideosViewsStats 128 'videos-views-stats': processVideosViewsStats,
129 'generate-video-storyboard': processGenerateStoryboard
126} 130}
127 131
128const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = { 132const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
@@ -141,10 +145,11 @@ const jobTypes: JobType[] = [
141 'after-video-channel-import', 145 'after-video-channel-import',
142 'email', 146 'email',
143 'federate-video', 147 'federate-video',
144 'transcoding-job-builder', 148 'generate-video-storyboard',
145 'manage-video-torrent', 149 'manage-video-torrent',
146 'move-to-object-storage', 150 'move-to-object-storage',
147 'notify', 151 'notify',
152 'transcoding-job-builder',
148 'video-channel-import', 153 'video-channel-import',
149 'video-file-import', 154 'video-file-import',
150 'video-import', 155 'video-import',