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
88 lines
2.7 KiB
TypeScript
88 lines
2.7 KiB
TypeScript
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<string> - Path to generated GIF
|
|
*/
|
|
export async function generateGifFromVideo(
|
|
videoPath: string,
|
|
outputPath: string,
|
|
options: GifOptions = {}
|
|
): Promise<string> {
|
|
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<number> - Duration in seconds
|
|
*/
|
|
export async function getVideoDuration(videoPath: string): Promise<number> {
|
|
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}`);
|
|
}
|
|
}
|
|
|