diff options
Diffstat (limited to 'server/lib/job-queue')
-rw-r--r-- | server/lib/job-queue/handlers/generate-storyboard.ts | 138 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/video-import.ts | 9 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/video-live-ending.ts | 20 | ||||
-rw-r--r-- | server/lib/job-queue/job-queue.ts | 11 |
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 @@ | |||
1 | import { Job } from 'bullmq' | ||
2 | import { join } from 'path' | ||
3 | import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg' | ||
4 | import { generateImageFilename, getImageSize } from '@server/helpers/image-utils' | ||
5 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
6 | import { CONFIG } from '@server/initializers/config' | ||
7 | import { STORYBOARD } from '@server/initializers/constants' | ||
8 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | ||
9 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
10 | import { StoryboardModel } from '@server/models/video/storyboard' | ||
11 | import { VideoModel } from '@server/models/video/video' | ||
12 | import { MVideo } from '@server/types/models' | ||
13 | import { FFmpegImage, isAudioFile } from '@shared/ffmpeg' | ||
14 | import { GenerateStoryboardPayload } from '@shared/models' | ||
15 | |||
16 | const lTagsBase = loggerTagsFactory('storyboard') | ||
17 | |||
18 | async 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 | |||
93 | export { | ||
94 | processGenerateStoryboard | ||
95 | } | ||
96 | |||
97 | function 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 | |||
107 | function 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 | |||
122 | function 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 | |||
130 | function 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 @@ | |||
1 | import { Job } from 'bullmq' | 1 | import { Job } from 'bullmq' |
2 | import { readdir, remove } from 'fs-extra' | 2 | import { readdir, remove } from 'fs-extra' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { peertubeTruncate } from '@server/helpers/core-utils' | ||
5 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' | ||
4 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | 6 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' |
5 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' | 7 | import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' |
6 | import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' | 8 | import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' |
@@ -20,8 +22,7 @@ import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@serv | |||
20 | import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg' | 22 | import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg' |
21 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' | 23 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' |
22 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | 24 | import { logger, loggerTagsFactory } from '../../../helpers/logger' |
23 | import { peertubeTruncate } from '@server/helpers/core-utils' | 25 | import { JobQueue } from '../job-queue' |
24 | import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' | ||
25 | 26 | ||
26 | const lTags = loggerTagsFactory('live', 'job') | 27 | const 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 | ||
152 | async function replaceLiveByReplay (options: { | 155 | async 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 | ||
218 | async function assignReplayFilesToVideo (options: { | 224 | async 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 | |||
287 | function 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' | |||
65 | import { processVideoStudioEdition } from './handlers/video-studio-edition' | 66 | import { processVideoStudioEdition } from './handlers/video-studio-edition' |
66 | import { processVideoTranscoding } from './handlers/video-transcoding' | 67 | import { processVideoTranscoding } from './handlers/video-transcoding' |
67 | import { processVideosViewsStats } from './handlers/video-views-stats' | 68 | import { processVideosViewsStats } from './handlers/video-views-stats' |
69 | import { processGenerateStoryboard } from './handlers/generate-storyboard' | ||
68 | 70 | ||
69 | export type CreateJobArgument = | 71 | export 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 | ||
96 | export type CreateJobOptions = { | 99 | export 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 | ||
128 | const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = { | 132 | const 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', |