Add automatic GIF preview generation for videos

Backend changes:
- Add ffmpeg to Docker image for video processing
- Create gifGenerator utility with high-quality palette-based GIF generation
- Update Prisma schema: Add GIF to MediaKind enum
- Auto-generate 300px GIF preview (10fps, 3sec) on video upload
- Add POST /api/terms/:id/media/:mediaId/regenerate-gif endpoint for admins
- Update delete endpoint to cascade delete GIFs when video is deleted
- GIF generation uses ffmpeg with palette optimization for quality

Frontend changes:
- Update MediaKind enum to include GIF
- Display animated GIF previews in dictionary word cards
- Show 'No preview' icon for videos without GIF
- Add 'Regenerate GIF' button in admin video upload UI
- Display both video and GIF side-by-side in admin panel
- Visual indicator when GIF preview is available

Features:
- Automatic GIF generation on video upload (non-blocking)
- Manual GIF regeneration from admin panel
- GIF preview in dictionary listing for better UX
- Fallback to video icon when no GIF available
- Proper cleanup: GIFs deleted when parent video is deleted

Technical details:
- GIFs stored in /uploads/gifs/
- Uses two-pass ffmpeg encoding with palette for quality
- Configurable fps, width, duration, start time
- Error handling: video upload succeeds even if GIF fails

Co-Authored-By: Auggie
This commit is contained in:
2026-01-18 17:51:08 +01:00
parent 1cff7740a8
commit acd14e635b
9 changed files with 443 additions and 33 deletions

View File

@@ -3,6 +3,7 @@ import { prisma } from '../lib/prisma.js';
import { WordType, CefrLevel, MediaKind } from '@prisma/client';
import { isAuthenticated, isAdmin } from '../middleware/auth.js';
import { videoUpload, handleUploadError } from '../middleware/upload.js';
import { generateGifFromVideo } from '../utils/gifGenerator.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -350,7 +351,7 @@ router.post(
// Create relative URL path
const relativeUrl = `/uploads/videos/${req.file.filename}`;
// Create TermMedia record
// Create TermMedia record for video
const media = await prisma.termMedia.create({
data: {
termId: id,
@@ -361,6 +362,43 @@ router.post(
},
});
// Generate GIF preview from video
try {
const videoPath = req.file.path;
const gifFilename = req.file.filename.replace(/\.(mp4|webm|mov)$/i, '.gif');
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
// Ensure gifs directory exists
if (!fs.existsSync(gifsDir)) {
fs.mkdirSync(gifsDir, { recursive: true });
}
const gifPath = path.join(gifsDir, gifFilename);
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
// Generate GIF (300px width, 10fps, first 3 seconds)
await generateGifFromVideo(videoPath, gifPath, {
width: 300,
fps: 10,
duration: 3,
startTime: 0,
});
// Create TermMedia record for GIF
await prisma.termMedia.create({
data: {
termId: id,
kind: MediaKind.GIF,
url: gifRelativeUrl,
},
});
console.log(`Generated GIF preview: ${gifRelativeUrl}`);
} catch (gifError: any) {
// Log error but don't fail the upload if GIF generation fails
console.error('Failed to generate GIF preview:', gifError.message);
}
res.status(201).json({ media });
} catch (error: any) {
console.error('Error uploading video:', error);
@@ -414,6 +452,51 @@ router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Reque
} catch (err) {
console.error(`Failed to delete video file ${filename}:`, err);
}
// Also delete corresponding GIF if it exists
const gifFilename = filename.replace(/\.(mp4|webm|mov)$/i, '.gif');
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
const gifPath = path.join(gifsDir, gifFilename);
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
try {
if (fs.existsSync(gifPath)) {
fs.unlinkSync(gifPath);
console.log(`Deleted GIF file: ${gifFilename}`);
}
// Delete GIF media record
const gifMedia = await prisma.termMedia.findFirst({
where: {
termId: id,
kind: MediaKind.GIF,
url: gifRelativeUrl,
},
});
if (gifMedia) {
await prisma.termMedia.delete({ where: { id: gifMedia.id } });
console.log(`Deleted GIF media record: ${gifMedia.id}`);
}
} catch (err) {
console.error(`Failed to delete GIF ${gifFilename}:`, err);
}
}
// Delete GIF file from disk if it's a GIF
if (media.kind === MediaKind.GIF && media.url) {
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
const filename = path.basename(media.url);
const filePath = path.join(gifsDir, filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`Deleted GIF file: ${filename}`);
}
} catch (err) {
console.error(`Failed to delete GIF file ${filename}:`, err);
}
}
// Delete TermMedia record
@@ -426,5 +509,93 @@ router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Reque
}
});
/**
* POST /api/terms/:id/media/:mediaId/regenerate-gif
* Regenerate GIF preview for a video (Admin only)
*/
router.post('/:id/media/:mediaId/regenerate-gif', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
try {
const { id, mediaId } = req.params;
// Check if media exists and belongs to the term
const media = await prisma.termMedia.findUnique({
where: { id: mediaId },
});
if (!media) {
return res.status(404).json({ error: 'Media not found' });
}
if (media.termId !== id) {
return res.status(400).json({ error: 'Media does not belong to this term' });
}
if (media.kind !== MediaKind.VIDEO) {
return res.status(400).json({ error: 'Can only regenerate GIF from video media' });
}
// Get video file path
const videoFilename = path.basename(media.url);
const videoPath = path.join(__dirname, '..', '..', 'uploads', 'videos', videoFilename);
if (!fs.existsSync(videoPath)) {
return res.status(404).json({ error: 'Video file not found on disk' });
}
// Generate GIF filename and path
const gifFilename = videoFilename.replace(/\.(mp4|webm|mov)$/i, '.gif');
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
// Ensure gifs directory exists
if (!fs.existsSync(gifsDir)) {
fs.mkdirSync(gifsDir, { recursive: true });
}
const gifPath = path.join(gifsDir, gifFilename);
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
// Delete old GIF if it exists
if (fs.existsSync(gifPath)) {
fs.unlinkSync(gifPath);
}
// Delete old GIF media record if it exists
const existingGif = await prisma.termMedia.findFirst({
where: {
termId: id,
kind: MediaKind.GIF,
url: gifRelativeUrl,
},
});
if (existingGif) {
await prisma.termMedia.delete({ where: { id: existingGif.id } });
}
// Generate new GIF (300px width, 10fps, first 3 seconds)
await generateGifFromVideo(videoPath, gifPath, {
width: 300,
fps: 10,
duration: 3,
startTime: 0,
});
// Create new TermMedia record for GIF
const gifMedia = await prisma.termMedia.create({
data: {
termId: id,
kind: MediaKind.GIF,
url: gifRelativeUrl,
},
});
console.log(`Regenerated GIF preview: ${gifRelativeUrl}`);
res.status(201).json({ media: gifMedia });
} catch (error: any) {
console.error('Error regenerating GIF:', error);
res.status(500).json({ error: 'Failed to regenerate GIF', message: error.message });
}
});
export default router;