import { existsSync, mkdirSync } from 'fs'; import { readFile, writeFile } from 'fs/promises'; import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); // Thumbnail cache directory (relative to project root) const THUMBNAIL_CACHE_DIR = path.join(process.cwd(), '.cache', 'video-thumbnails'); const THUMBNAIL_QUALITY = 85; // JPEG quality const THUMBNAIL_MAX_WIDTH = 800; // Max width for thumbnails // Ensure cache directory exists function ensureCacheDir() { if (!existsSync(THUMBNAIL_CACHE_DIR)) { mkdirSync(THUMBNAIL_CACHE_DIR, { recursive: true }); } } /** * Get thumbnail path for a video file */ function getThumbnailPath(videoPath: string): string { ensureCacheDir(); // Create a hash-like filename from the video path // Use a simple approach: replace path separators and special chars const safePath = videoPath.replace(/[^a-zA-Z0-9]/g, '_'); const hash = Buffer.from(safePath).toString('base64').slice(0, 32); return path.join(THUMBNAIL_CACHE_DIR, `${hash}.jpg`); } /** * Check if ffmpeg is available */ async function isFfmpegAvailable(): Promise { try { await execAsync('ffmpeg -version'); return true; } catch { return false; } } /** * Generate video thumbnail using ffmpeg * Extracts frame at 1 second (or first frame if video is shorter) */ async function generateThumbnailWithFfmpeg( videoPath: string, thumbnailPath: string ): Promise { // Escape paths properly for shell commands const escapedVideoPath = videoPath.replace(/'/g, "'\"'\"'"); const escapedThumbnailPath = thumbnailPath.replace(/'/g, "'\"'\"'"); // Extract frame at 1 second, or first frame if video is shorter // Scale to max width while maintaining aspect ratio // Use -loglevel error to suppress ffmpeg output unless there's an error const command = `ffmpeg -i '${escapedVideoPath}' -ss 00:00:01 -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`; try { const { stdout, stderr } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 }); if (stderr && !stderr.includes('frame=')) { console.warn('ffmpeg stderr:', stderr); } // Wait a bit for file to be written await new Promise(resolve => setTimeout(resolve, 100)); // Read the generated thumbnail if (existsSync(thumbnailPath)) { const buffer = await readFile(thumbnailPath); if (buffer.length > 0) { return buffer; } throw new Error('Thumbnail file is empty'); } throw new Error('Thumbnail file was not created'); } catch (error) { console.error(`Error extracting frame at 1s for ${videoPath}:`, error); // If extraction at 1s fails, try first frame try { const fallbackCommand = `ffmpeg -i '${escapedVideoPath}' -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`; const { stdout, stderr } = await execAsync(fallbackCommand, { maxBuffer: 10 * 1024 * 1024 }); if (stderr && !stderr.includes('frame=')) { console.warn('ffmpeg fallback stderr:', stderr); } // Wait a bit for file to be written await new Promise(resolve => setTimeout(resolve, 100)); if (existsSync(thumbnailPath)) { const buffer = await readFile(thumbnailPath); if (buffer.length > 0) { return buffer; } throw new Error('Fallback thumbnail file is empty'); } } catch (fallbackError) { console.error(`Error generating video thumbnail (fallback failed) for ${videoPath}:`, fallbackError); throw new Error(`Failed to generate video thumbnail: ${fallbackError instanceof Error ? fallbackError.message : 'Unknown error'}`); } throw error; } } /** * Generate or retrieve cached video thumbnail * Returns thumbnail buffer if successful, null if ffmpeg is not available */ export async function getVideoThumbnail(videoPath: string): Promise { // Check if ffmpeg is available const ffmpegAvailable = await isFfmpegAvailable(); if (!ffmpegAvailable) { console.warn('ffmpeg is not available. Video thumbnails will not be generated.'); return null; } const thumbnailPath = getThumbnailPath(videoPath); // Check if thumbnail already exists in cache if (existsSync(thumbnailPath)) { try { return await readFile(thumbnailPath); } catch (error) { console.error('Error reading cached thumbnail:', error); // Continue to regenerate } } // Check if video file exists if (!existsSync(videoPath)) { console.error(`Video file not found: ${videoPath}`); return null; } // Generate new thumbnail try { const thumbnailBuffer = await generateThumbnailWithFfmpeg(videoPath, thumbnailPath); return thumbnailBuffer; } catch (error) { console.error(`Error generating thumbnail for ${videoPath}:`, error); return null; } } /** * Check if a thumbnail exists in cache */ export function hasCachedThumbnail(videoPath: string): boolean { const thumbnailPath = getThumbnailPath(videoPath); return existsSync(thumbnailPath); } /** * Get cached thumbnail path (for direct file serving) */ export function getCachedThumbnailPath(videoPath: string): string | null { const thumbnailPath = getThumbnailPath(videoPath); return existsSync(thumbnailPath) ? thumbnailPath : null; }