Files
znakovni.hr/packages/backend/src/routes/terms.ts
johnny2211 acd14e635b 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
2026-01-18 17:51:08 +01:00

602 lines
17 KiB
TypeScript

import { Router, Request, Response } from 'express';
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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const router = Router();
/**
* GET /api/terms
* Get all terms with optional filtering, search, and pagination
* Query params:
* - query: search text (searches wordText and normalizedText)
* - wordType: filter by word type (NOUN, VERB, etc.)
* - cefrLevel: filter by CEFR level (A1, A2, B1, B2, C1, C2)
* - page: page number (default: 1)
* - limit: items per page (default: 20)
*/
router.get('/', async (req: Request, res: Response) => {
try {
const {
query = '',
wordType,
cefrLevel,
page = '1',
limit = '20',
} = req.query;
const pageNum = parseInt(page as string, 10);
const limitNum = parseInt(limit as string, 10);
const skip = (pageNum - 1) * limitNum;
// Build where clause
const where: any = {};
// Add search filter
if (query && typeof query === 'string' && query.trim()) {
where.OR = [
{ wordText: { contains: query.trim() } },
{ normalizedText: { contains: query.trim() } },
];
}
// Add wordType filter
if (wordType && typeof wordType === 'string') {
const validWordTypes = Object.values(WordType);
if (validWordTypes.includes(wordType as WordType)) {
where.wordType = wordType as WordType;
}
}
// Add cefrLevel filter
if (cefrLevel && typeof cefrLevel === 'string') {
const validCefrLevels = Object.values(CefrLevel);
if (validCefrLevels.includes(cefrLevel as CefrLevel)) {
where.cefrLevel = cefrLevel as CefrLevel;
}
}
// Get total count for pagination
const total = await prisma.term.count({ where });
// Get terms with media
const terms = await prisma.term.findMany({
where,
include: {
media: {
orderBy: {
createdAt: 'asc',
},
},
examples: {
orderBy: {
createdAt: 'asc',
},
},
},
orderBy: {
wordText: 'asc',
},
skip,
take: limitNum,
});
res.json({
terms,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
},
});
} catch (error: any) {
console.error('Error fetching terms:', error);
res.status(500).json({ error: 'Failed to fetch terms', message: error.message });
}
});
/**
* GET /api/terms/:id
* Get a single term by ID with all related data
*/
router.get('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const term = await prisma.term.findUnique({
where: { id },
include: {
media: {
orderBy: {
createdAt: 'asc',
},
},
examples: {
orderBy: {
createdAt: 'asc',
},
},
},
});
if (!term) {
return res.status(404).json({ error: 'Term not found' });
}
res.json({ term });
} catch (error: any) {
console.error('Error fetching term:', error);
res.status(500).json({ error: 'Failed to fetch term', message: error.message });
}
});
/**
* Helper function to normalize text (lowercase, remove diacritics)
*/
function normalizeText(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, ''); // Remove diacritics
}
/**
* POST /api/terms
* Create a new term (Admin only)
*/
router.post('/', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
try {
const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body;
// Validate required fields
if (!wordText || !wordType || !cefrLevel) {
return res.status(400).json({
error: 'Validation error',
message: 'wordText, wordType, and cefrLevel are required',
});
}
// Validate wordType
const validWordTypes = Object.values(WordType);
if (!validWordTypes.includes(wordType as WordType)) {
return res.status(400).json({
error: 'Validation error',
message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`,
});
}
// Validate cefrLevel
const validCefrLevels = Object.values(CefrLevel);
if (!validCefrLevels.includes(cefrLevel as CefrLevel)) {
return res.status(400).json({
error: 'Validation error',
message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`,
});
}
// Create term with auto-generated normalizedText
const term = await prisma.term.create({
data: {
wordText,
normalizedText: normalizeText(wordText),
wordType: wordType as WordType,
cefrLevel: cefrLevel as CefrLevel,
shortDescription: shortDescription || null,
tags: tags || null,
iconAssetId: iconAssetId || null,
},
include: {
media: true,
examples: true,
},
});
res.status(201).json({ term });
} catch (error: any) {
console.error('Error creating term:', error);
res.status(500).json({ error: 'Failed to create term', message: error.message });
}
});
/**
* PUT /api/terms/:id
* Update an existing term (Admin only)
*/
router.put('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body;
// Check if term exists
const existingTerm = await prisma.term.findUnique({ where: { id } });
if (!existingTerm) {
return res.status(404).json({ error: 'Term not found' });
}
// Build update data
const updateData: any = {};
if (wordText !== undefined) {
updateData.wordText = wordText;
updateData.normalizedText = normalizeText(wordText);
}
if (wordType !== undefined) {
const validWordTypes = Object.values(WordType);
if (!validWordTypes.includes(wordType as WordType)) {
return res.status(400).json({
error: 'Validation error',
message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`,
});
}
updateData.wordType = wordType as WordType;
}
if (cefrLevel !== undefined) {
const validCefrLevels = Object.values(CefrLevel);
if (!validCefrLevels.includes(cefrLevel as CefrLevel)) {
return res.status(400).json({
error: 'Validation error',
message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`,
});
}
updateData.cefrLevel = cefrLevel as CefrLevel;
}
if (shortDescription !== undefined) updateData.shortDescription = shortDescription || null;
if (tags !== undefined) updateData.tags = tags || null;
if (iconAssetId !== undefined) updateData.iconAssetId = iconAssetId || null;
// Update term
const term = await prisma.term.update({
where: { id },
data: updateData,
include: {
media: true,
examples: true,
},
});
res.json({ term });
} catch (error: any) {
console.error('Error updating term:', error);
res.status(500).json({ error: 'Failed to update term', message: error.message });
}
});
/**
* DELETE /api/terms/:id
* Delete a term and its associated media (Admin only)
*/
router.delete('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
try {
const { id } = req.params;
// Check if term exists and get associated media
const term = await prisma.term.findUnique({
where: { id },
include: { media: true },
});
if (!term) {
return res.status(404).json({ error: 'Term not found' });
}
// Delete associated video files from disk
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos');
for (const media of term.media) {
if (media.kind === MediaKind.VIDEO && media.url) {
const filename = path.basename(media.url);
const filePath = path.join(uploadsDir, filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`Deleted video file: ${filename}`);
}
} catch (err) {
console.error(`Failed to delete video file ${filename}:`, err);
}
}
}
// Delete term (cascade will delete TermMedia records)
await prisma.term.delete({ where: { id } });
res.json({ message: 'Term deleted successfully' });
} catch (error: any) {
console.error('Error deleting term:', error);
res.status(500).json({ error: 'Failed to delete term', message: error.message });
}
});
/**
* POST /api/terms/:id/media
* Upload a video for a term (Admin only)
*/
router.post(
'/:id/media',
isAuthenticated,
isAdmin,
videoUpload.single('video'),
handleUploadError,
async (req: Request, res: Response) => {
try {
const { id } = req.params;
// Check if term exists
const term = await prisma.term.findUnique({ where: { id } });
if (!term) {
// Clean up uploaded file if term doesn't exist
if (req.file) {
fs.unlinkSync(req.file.path);
}
return res.status(404).json({ error: 'Term not found' });
}
// Check if file was uploaded
if (!req.file) {
return res.status(400).json({ error: 'No video file uploaded' });
}
// Create relative URL path
const relativeUrl = `/uploads/videos/${req.file.filename}`;
// Create TermMedia record for video
const media = await prisma.termMedia.create({
data: {
termId: id,
kind: MediaKind.VIDEO,
url: relativeUrl,
// TODO: Extract video metadata (duration, dimensions) using ffprobe if available
// For now, these fields will be null
},
});
// 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);
// Clean up uploaded file on error
if (req.file) {
try {
fs.unlinkSync(req.file.path);
} catch (err) {
console.error('Failed to clean up file:', err);
}
}
res.status(500).json({ error: 'Failed to upload video', message: error.message });
}
}
);
/**
* DELETE /api/terms/:id/media/:mediaId
* Delete a video from a term (Admin only)
*/
router.delete('/:id/media/:mediaId', 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' });
}
// Delete video file from disk if it's a video
if (media.kind === MediaKind.VIDEO && media.url) {
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos');
const filename = path.basename(media.url);
const filePath = path.join(uploadsDir, filename);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
console.log(`Deleted video file: ${filename}`);
}
} 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
await prisma.termMedia.delete({ where: { id: mediaId } });
res.json({ message: 'Media deleted successfully' });
} catch (error: any) {
console.error('Error deleting media:', error);
res.status(500).json({ error: 'Failed to delete media', message: error.message });
}
});
/**
* 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;