From acd14e635bdfc3b4ba89d9dc64b91dadfbaa3fee Mon Sep 17 00:00:00 2001 From: johnny2211 Date: Sun, 18 Jan 2026 17:51:08 +0100 Subject: [PATCH] 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 --- .gitignore | 2 +- Dockerfile | 92 ++++++++++ .../migration.sql | 3 + packages/backend/prisma/schema.prisma | 2 + packages/backend/src/routes/terms.ts | 173 +++++++++++++++++- packages/backend/src/utils/gifGenerator.ts | 87 +++++++++ .../src/components/admin/VideoUpload.tsx | 93 +++++++--- .../src/components/dictionary/WordCard.tsx | 23 ++- packages/frontend/src/types/term.ts | 1 + 9 files changed, 443 insertions(+), 33 deletions(-) create mode 100644 Dockerfile create mode 100644 packages/backend/prisma/migrations/20260118174118_add_gif_media_kind/migration.sql create mode 100644 packages/backend/src/utils/gifGenerator.ts diff --git a/.gitignore b/.gitignore index bf07849..0eb35f5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ coverage/ packages/backend/uploads/ # Prisma -packages/backend/prisma/migrations/ +!packages/backend/prisma/migrations/ !packages/backend/prisma/migrations/.gitkeep # Temporary files diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7f40787 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,92 @@ +# =========================================== +# Stage 1: Build Frontend +# =========================================== +FROM node:20-alpine AS frontend-builder + +WORKDIR /app + +# Copy workspace configuration files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/frontend/package.json ./packages/frontend/ + +# Install pnpm and dependencies +RUN npm install -g pnpm && \ + pnpm install --frozen-lockfile + +# Copy frontend source code +COPY packages/frontend ./packages/frontend + +# Build frontend +RUN cd packages/frontend && pnpm build + +# =========================================== +# Stage 2: Build Backend +# =========================================== +FROM node:20-alpine AS backend-builder + +WORKDIR /app + +# Copy workspace configuration files +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/backend/package.json ./packages/backend/ + +# Install pnpm and dependencies +RUN npm install -g pnpm && \ + pnpm install --frozen-lockfile + +# Copy backend source code +COPY packages/backend ./packages/backend + +# Generate Prisma client and build backend +RUN cd packages/backend && \ + npx prisma generate && \ + pnpm build + +# =========================================== +# Stage 3: Production Runtime +# =========================================== +FROM node:20-alpine AS production + +WORKDIR /app + +# Install OpenSSL for Prisma compatibility, nginx for reverse proxy, and ffmpeg for video processing +RUN apk add --no-cache openssl nginx ffmpeg + +# Install pnpm globally +RUN npm install -g pnpm + +# Copy workspace configuration files +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY packages/backend/package.json ./packages/backend/ +COPY packages/frontend/package.json ./packages/frontend/ + +# Install ALL dependencies (including Prisma CLI needed for migrations) +RUN pnpm install --frozen-lockfile + +# Copy backend build artifacts +COPY --from=backend-builder /app/packages/backend/dist ./packages/backend/dist +COPY --from=backend-builder /app/packages/backend/prisma ./packages/backend/prisma + +# Copy Prisma generated client (in pnpm workspace structure) +# We need to copy the entire .pnpm directory to preserve the Prisma client +COPY --from=backend-builder /app/node_modules/.pnpm ./node_modules/.pnpm + +# Copy frontend build artifacts +COPY --from=frontend-builder /app/packages/frontend/dist ./packages/frontend/dist + +# Create uploads directory +RUN mkdir -p /app/packages/backend/uploads + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy startup script +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +# Expose frontend port +EXPOSE 5173 + +# Set entrypoint +CMD ["/app/docker-entrypoint.sh"] + diff --git a/packages/backend/prisma/migrations/20260118174118_add_gif_media_kind/migration.sql b/packages/backend/prisma/migrations/20260118174118_add_gif_media_kind/migration.sql new file mode 100644 index 0000000..300b468 --- /dev/null +++ b/packages/backend/prisma/migrations/20260118174118_add_gif_media_kind/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TABLE `term_media` MODIFY `kind` ENUM('VIDEO', 'IMAGE', 'ILLUSTRATION', 'GIF') NOT NULL; + diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index ee6b8ae..0fa4d97 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" previewFeatures = ["fullTextIndex"] + binaryTargets = ["native", "linux-musl-openssl-3.0.x"] } datasource db { @@ -223,6 +224,7 @@ enum MediaKind { VIDEO IMAGE ILLUSTRATION + GIF } enum Visibility { diff --git a/packages/backend/src/routes/terms.ts b/packages/backend/src/routes/terms.ts index b2f0a02..328d5d9 100644 --- a/packages/backend/src/routes/terms.ts +++ b/packages/backend/src/routes/terms.ts @@ -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; diff --git a/packages/backend/src/utils/gifGenerator.ts b/packages/backend/src/utils/gifGenerator.ts new file mode 100644 index 0000000..437cd77 --- /dev/null +++ b/packages/backend/src/utils/gifGenerator.ts @@ -0,0 +1,87 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import fs from 'fs'; + +const execAsync = promisify(exec); + +export interface GifOptions { + fps?: number; // Frames per second (default: 10) + width?: number; // Width in pixels (default: 300) + duration?: number; // Duration in seconds to capture (default: 3) + startTime?: number; // Start time in seconds (default: 0) +} + +/** + * Generate a GIF from a video file using ffmpeg + * @param videoPath - Absolute path to the video file + * @param outputPath - Absolute path where GIF should be saved + * @param options - GIF generation options + * @returns Promise - Path to generated GIF + */ +export async function generateGifFromVideo( + videoPath: string, + outputPath: string, + options: GifOptions = {} +): Promise { + const { + fps = 10, + width = 300, + duration = 3, + startTime = 0, + } = options; + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Build ffmpeg command + // -ss: start time + // -t: duration + // -vf: video filters (fps, scale, palettegen/paletteuse for better quality) + const paletteFile = path.join(outputDir, `palette-${Date.now()}.png`); + + try { + // Step 1: Generate color palette for better GIF quality + const paletteCmd = `ffmpeg -ss ${startTime} -t ${duration} -i "${videoPath}" -vf "fps=${fps},scale=${width}:-1:flags=lanczos,palettegen" -y "${paletteFile}"`; + await execAsync(paletteCmd); + + // Step 2: Generate GIF using the palette + const gifCmd = `ffmpeg -ss ${startTime} -t ${duration} -i "${videoPath}" -i "${paletteFile}" -lavfi "fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse" -y "${outputPath}"`; + await execAsync(gifCmd); + + // Clean up palette file + if (fs.existsSync(paletteFile)) { + fs.unlinkSync(paletteFile); + } + + return outputPath; + } catch (error: any) { + // Clean up on error + if (fs.existsSync(paletteFile)) { + fs.unlinkSync(paletteFile); + } + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + throw new Error(`Failed to generate GIF: ${error.message}`); + } +} + +/** + * Get video duration in seconds using ffprobe + * @param videoPath - Absolute path to the video file + * @returns Promise - Duration in seconds + */ +export async function getVideoDuration(videoPath: string): Promise { + try { + const cmd = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`; + const { stdout } = await execAsync(cmd); + return parseFloat(stdout.trim()); + } catch (error: any) { + throw new Error(`Failed to get video duration: ${error.message}`); + } +} + diff --git a/packages/frontend/src/components/admin/VideoUpload.tsx b/packages/frontend/src/components/admin/VideoUpload.tsx index e3dca29..af000d3 100644 --- a/packages/frontend/src/components/admin/VideoUpload.tsx +++ b/packages/frontend/src/components/admin/VideoUpload.tsx @@ -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(null); const fileInputRef = useRef(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 (
{/* Existing Videos */} {existingVideos.length > 0 && (

Postojeći videozapisi

- {existingVideos.map((media) => ( -
-
)} diff --git a/packages/frontend/src/components/dictionary/WordCard.tsx b/packages/frontend/src/components/dictionary/WordCard.tsx index 1355f5a..222dcb0 100644 --- a/packages/frontend/src/components/dictionary/WordCard.tsx +++ b/packages/frontend/src/components/dictionary/WordCard.tsx @@ -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 = { 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) {
- {/* Icon/Image */} + {/* Icon/Image/GIF Preview */}
- {imageMedia ? ( - {term.wordText} + ) : imageMedia ? ( + {term.wordText} ) : videoMedia ? ( -
- Video +
+
) : (
diff --git a/packages/frontend/src/types/term.ts b/packages/frontend/src/types/term.ts index f65623c..a49ae42 100644 --- a/packages/frontend/src/types/term.ts +++ b/packages/frontend/src/types/term.ts @@ -24,6 +24,7 @@ export enum MediaKind { VIDEO = 'VIDEO', IMAGE = 'IMAGE', ILLUSTRATION = 'ILLUSTRATION', + GIF = 'GIF', } export interface TermMedia {