Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions lib/FF.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
62 changes: 62 additions & 0 deletions services/job-service/queue/BullQueue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
92 changes: 91 additions & 1 deletion services/video-service/controllers/videoController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
3 changes: 3 additions & 0 deletions services/video-service/routes/videoRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down