From 35ed7f657249fddf9309931f753a769541a8b78a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Dec 2025 16:56:31 +0000 Subject: [PATCH] Add GIF creation feature from videos with FFmpeg This PR adds functionality to convert videos to animated GIFs using FFmpeg with high-quality palette generation: Features: - GIF creation endpoint (POST /create-gif) with FFmpeg-based conversion - Configurable options: - FPS (frames per second, 1-30, default: 10) - Width (100-1920 pixels, default: 320) - Start time (optional, for partial conversion) - Duration (optional, for partial conversion) - High-quality GIF output with custom palette generation - Looping GIFs (infinite loop) - Background job processing via Bull queue with progress tracking - Event-driven architecture using VIDEO_PROCESSING_REQUESTED events Technical Changes: - Added createGif() function to lib/FF.js using FFmpeg with palette filters - Added createGif() controller to video-service with validation - Added /create-gif route - Added create-gif processor to BullQueue with progress updates - Operation type 'create-gif' for tracking in database - File naming: storage/{videoId}/animated.gif ### FFmpeg Command ```bash ffmpeg -i input.mp4 -vf "fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 output.gif ``` The filter uses: - fps: Control frame rate - scale: Resize with aspect ratio preservation - lanczos: High-quality scaling algorithm - palettegen + paletteuse: Generate custom color palette for better GIF quality - loop 0: Infinite looping This is part 5 of 5 feature PRs for enhanced media processing capabilities. --- lib/FF.js | 62 +++++++++++++ services/job-service/queue/BullQueue.js | 62 +++++++++++++ .../controllers/videoController.js | 92 ++++++++++++++++++- services/video-service/routes/videoRoutes.js | 3 + 4 files changed, 218 insertions(+), 1 deletion(-) diff --git a/lib/FF.js b/lib/FF.js index fb1daa4..e65f47d 100644 --- a/lib/FF.js +++ b/lib/FF.js @@ -202,10 +202,72 @@ const convertFormat = (originalVideoPath, targetVideoPath, targetFormat) => { }; +/** + * Create an animated GIF from a video + * @param {string} originalVideoPath - Source video path + * @param {string} targetGifPath - Output GIF path + * @param {object} options - GIF options (fps, width, startTime, duration) + */ +const createGif = (originalVideoPath, targetGifPath, options = {}) => { + return new Promise((resolve, reject) => { + const { + fps = 10, + width = 320, + startTime, + duration + } = options; + + // Build FFmpeg arguments + const args = ["-i", originalVideoPath]; + + // Add start time if specified + if (startTime !== undefined) { + args.push("-ss", startTime.toString()); + } + + // Add duration if specified + if (duration !== undefined) { + args.push("-t", duration.toString()); + } + + // Add filter for high-quality GIF with palette generation + // This creates a custom palette and then uses it for better colors + args.push( + "-vf", + `fps=${fps},scale=${width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`, + "-loop", + "0", // Loop forever + "-loglevel", + "error", + "-y", + targetGifPath + ); + + const ffmpeg = spawn("ffmpeg", args); + + ffmpeg.stderr.on("data", (data) => { + console.log(data.toString("utf8")); + }); + + ffmpeg.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(`FFmpeg exited with this code: ${code}`); + } + }); + + ffmpeg.on("error", (err) => { + reject(err); + }); + }); +}; + module.exports = { makeThumbnail, getDimensions, extractAudio, resize, convertFormat, + createGif, }; diff --git a/services/job-service/queue/BullQueue.js b/services/job-service/queue/BullQueue.js index 4dad9ca..2c5c092 100644 --- a/services/job-service/queue/BullQueue.js +++ b/services/job-service/queue/BullQueue.js @@ -57,6 +57,11 @@ class BullQueue extends EventEmitter { this.queue.process('convert', this.CONCURRENCY, async (bullJob) => { return this.processConvert(bullJob); }); + + // Process create-gif jobs + this.queue.process('create-gif', this.CONCURRENCY, async (bullJob) => { + return this.processCreateGif(bullJob); + }); } setupBullEventListeners() { @@ -339,6 +344,63 @@ class BullQueue extends EventEmitter { } } + async processCreateGif(bullJob) { + const { videoId, fps, width, startTime, duration } = bullJob.data; + + const video = await videoService.findByVideoId(videoId); + if (!video) { + throw new Error(`Video ${videoId} not found`); + } + + const originalVideoPath = `./storage/${video.video_id}/original.${video.extension}`; + const targetGifPath = `./storage/${video.video_id}/animated.gif`; + + // Find the operation + const operation = await videoService.findOperation(videoId, 'create-gif', {}); + + try { + // Update progress: Starting + await bullJob.progress(10); + + // Update operation status to processing + if (operation) { + await videoService.updateOperationStatus(operation.id, 'processing'); + } + + // Create GIF + await bullJob.progress(25); + const options = { fps, width, startTime, duration }; + // Remove undefined values + Object.keys(options).forEach(key => options[key] === undefined && delete options[key]); + await FF.createGif(originalVideoPath, targetGifPath, options); + + // Update progress: Processing complete + await bullJob.progress(75); + + // Update operation status to completed + if (operation) { + await videoService.updateOperationStatus(operation.id, 'completed', targetGifPath); + } + + // Complete + await bullJob.progress(100); + + console.log(`✅ GIF created from video ${videoId}`); + + return JSON.stringify({ ...options, gifPath: targetGifPath }); + } catch (error) { + console.error(`❌ GIF creation failed:`, error); + util.deleteFile(targetGifPath); + + // Update operation status to failed + if (operation) { + await videoService.updateOperationStatus(operation.id, 'failed', null, error.message); + } + + throw error; + } + } + // Helper methods for queue management async getQueueStats() { const waiting = await this.queue.getWaitingCount(); diff --git a/services/video-service/controllers/videoController.js b/services/video-service/controllers/videoController.js index 92699e5..cc9872d 100644 --- a/services/video-service/controllers/videoController.js +++ b/services/video-service/controllers/videoController.js @@ -407,11 +407,101 @@ const getVideoAsset = async (req, res) => { } }; +/** + * Create GIF from video (queue job) + * POST /create-gif + */ +const createGif = async (req, res) => { + const { videoId, fps, width, startTime, duration } = req.body; + + // Validate required field + if (!videoId) { + return res.status(400).json({ + error: "videoId is required!" + }); + } + + // Validate optional parameters + if (fps !== undefined && (fps < 1 || fps > 30)) { + return res.status(400).json({ + error: "FPS must be between 1 and 30." + }); + } + + if (width !== undefined && (width < 100 || width > 1920)) { + return res.status(400).json({ + error: "Width must be between 100 and 1920 pixels." + }); + } + + if (startTime !== undefined && startTime < 0) { + return res.status(400).json({ + error: "Start time must be non-negative." + }); + } + + if (duration !== undefined && duration <= 0) { + return res.status(400).json({ + error: "Duration must be positive." + }); + } + + try { + // Check if video exists + const video = await videoService.findByVideoId(videoId); + if (!video) { + return res.status(404).json({ error: "Video not found." }); + } + + // Build GIF options + const options = {}; + if (fps !== undefined) options.fps = fps; + if (width !== undefined) options.width = width; + if (startTime !== undefined) options.startTime = startTime; + if (duration !== undefined) options.duration = duration; + + // Add create-gif operation to database + await videoService.addOperation(videoId, { + type: 'create-gif', + status: 'pending', + parameters: options + }); + + // Publish VIDEO_PROCESSING_REQUESTED event + try { + await req.app.locals.eventBus.publish(EventTypes.VIDEO_PROCESSING_REQUESTED, { + videoId, + userId: req.userId, + operation: 'create-gif', + parameters: options + }); + console.log(`[Video Service] Published VIDEO_PROCESSING_REQUESTED event for videoId: ${videoId}`); + + res.status(200).json({ + status: "success", + message: "The GIF is now being created!" + }); + } catch (error) { + console.error("[Video Service] Failed to publish event:", error.message); + res.status(500).json({ + error: "Failed to start GIF creation.", + details: "Event bus unavailable" + }); + } + } catch (error) { + console.error("[Video Service] Create GIF error:", error); + res.status(500).json({ + error: "Failed to start GIF creation." + }); + } +}; + module.exports = { getVideos, uploadVideo, extractAudio, resizeVideo, convertVideo, - getVideoAsset + getVideoAsset, + createGif }; diff --git a/services/video-service/routes/videoRoutes.js b/services/video-service/routes/videoRoutes.js index f9ac832..c0d6d08 100644 --- a/services/video-service/routes/videoRoutes.js +++ b/services/video-service/routes/videoRoutes.js @@ -22,6 +22,9 @@ router.post('/resize', authMiddleware.authenticate, videoController.resizeVideo) // Convert video format (queue job) router.post('/convert', authMiddleware.authenticate, videoController.convertVideo); +// Create GIF from video (queue job) +router.post('/create-gif', authMiddleware.authenticate, videoController.createGif); + // Get video asset (original, thumbnail, resized, etc.) router.get('/asset', authMiddleware.authenticate, videoController.getVideoAsset);