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

@@ -1,6 +1,6 @@
import { useState, useRef, DragEvent } from 'react';
import { Button } from '../ui/button';
import { Upload, Trash2, X } from 'lucide-react';
import { Upload, Trash2, X, RefreshCw } from 'lucide-react';
import api from '../../lib/api';
import { Term, MediaKind } from '../../types/term';
import { toast } from 'sonner';
@@ -19,9 +19,11 @@ export function VideoUpload({ term, onSuccess, onCancel }: VideoUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [regeneratingGif, setRegeneratingGif] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const existingVideos = term.media?.filter(m => m.kind === MediaKind.VIDEO) || [];
const existingGifs = term.media?.filter(m => m.kind === MediaKind.GIF) || [];
// Construct full video URL
const API_URL = import.meta.env.VITE_API_URL !== undefined ? import.meta.env.VITE_API_URL : 'http://localhost:3000';
@@ -112,36 +114,79 @@ export function VideoUpload({ term, onSuccess, onCancel }: VideoUploadProps) {
}
};
const handleRegenerateGif = async (videoMediaId: string) => {
setRegeneratingGif(videoMediaId);
try {
await api.post(`/api/terms/${term.id}/media/${videoMediaId}/regenerate-gif`);
toast.success('GIF uspješno regeneriran');
onSuccess();
} catch (err: any) {
toast.error(err.response?.data?.message || 'Greška pri regeneraciji GIF-a');
} finally {
setRegeneratingGif(null);
}
};
return (
<div className="space-y-4">
{/* Existing Videos */}
{existingVideos.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium">Postojeći videozapisi</h3>
{existingVideos.map((media) => (
<div key={media.id} className="flex items-center gap-4 p-4 border rounded-lg">
<video
src={getVideoUrl(media.url)}
className="w-48 h-32 object-cover rounded"
controls
/>
<div className="flex-1">
<p className="text-sm text-gray-600">{media.url}</p>
{media.durationMs && (
<p className="text-xs text-gray-500">
Trajanje: {(media.durationMs / 1000).toFixed(1)}s
</p>
)}
{existingVideos.map((media) => {
// Find corresponding GIF for this video
const gifFilename = media.url.replace(/\.(mp4|webm|mov)$/i, '.gif').replace('/uploads/videos/', '/uploads/gifs/');
const correspondingGif = existingGifs.find(g => g.url === gifFilename);
return (
<div key={media.id} className="flex items-center gap-4 p-4 border rounded-lg">
<div className="flex gap-2">
<video
src={getVideoUrl(media.url)}
className="w-48 h-32 object-cover rounded"
controls
/>
{correspondingGif && (
<img
src={getVideoUrl(correspondingGif.url)}
alt="GIF preview"
className="w-48 h-32 object-cover rounded border-2 border-orange-500"
title="GIF preview"
/>
)}
</div>
<div className="flex-1">
<p className="text-sm text-gray-600">{media.url}</p>
{media.durationMs && (
<p className="text-xs text-gray-500">
Trajanje: {(media.durationMs / 1000).toFixed(1)}s
</p>
)}
{correspondingGif && (
<p className="text-xs text-green-600 mt-1"> GIF preview dostupan</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleRegenerateGif(media.id)}
disabled={regeneratingGif === media.id}
title="Regeneriraj GIF preview"
>
<RefreshCw className={`h-4 w-4 ${regeneratingGif === media.id ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteVideo(media.id)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteVideo(media.id)}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
))}
);
})}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { Info, Plus } from 'lucide-react';
import { Info, Plus, Video } from 'lucide-react';
import { Term, CefrLevel } from '../../types/term';
import { Button } from '../ui/button';
import { wordTypeColors, wordTypeLabels } from '../../lib/wordTypeColors';
@@ -20,6 +20,7 @@ const cefrColors: Record<CefrLevel, string> = {
export function WordCard({ term, onInfo, onAddToSentence }: WordCardProps) {
const videoMedia = term.media?.find(m => m.kind === 'VIDEO');
const gifMedia = term.media?.find(m => m.kind === 'GIF');
const imageMedia = term.media?.find(m => m.kind === 'IMAGE' || m.kind === 'ILLUSTRATION');
return (
@@ -34,17 +35,25 @@ export function WordCard({ term, onInfo, onAddToSentence }: WordCardProps) {
</span>
</div>
{/* Icon/Image */}
{/* Icon/Image/GIF Preview */}
<div className="flex-1 flex items-center justify-center mb-4 min-h-[120px]">
{imageMedia ? (
<img
src={imageMedia.url}
{gifMedia ? (
<img
src={gifMedia.url}
alt={term.wordText}
className="max-h-[120px] max-w-full object-contain rounded"
title="Video preview"
/>
) : imageMedia ? (
<img
src={imageMedia.url}
alt={term.wordText}
className="max-h-[120px] max-w-full object-contain"
/>
) : videoMedia ? (
<div className="w-full h-[120px] bg-gray-100 rounded flex items-center justify-center">
<span className="text-gray-400 text-sm">Video</span>
<div className="w-full h-[120px] bg-gray-100 rounded flex flex-col items-center justify-center gap-2">
<Video className="h-8 w-8 text-gray-400" />
<span className="text-gray-400 text-xs">Nema pregleda</span>
</div>
) : (
<div className="w-full h-[120px] bg-gray-100 rounded flex items-center justify-center">

View File

@@ -24,6 +24,7 @@ export enum MediaKind {
VIDEO = 'VIDEO',
IMAGE = 'IMAGE',
ILLUSTRATION = 'ILLUSTRATION',
GIF = 'GIF',
}
export interface TermMedia {