aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/job-queue/handlers/generate-storyboard.ts
diff options
context:
space:
mode:
Diffstat (limited to 'server/lib/job-queue/handlers/generate-storyboard.ts')
-rw-r--r--server/lib/job-queue/handlers/generate-storyboard.ts138
1 files changed, 138 insertions, 0 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}