import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; import { execFile as execFileCb } from 'child_process'; import { promisify } from 'util'; const execFile = promisify(execFileCb); const FFMPEG_TIMEOUT = 10_000; // 10 seconds let ffmpegAvailable: boolean | null = null; const PROFILE_LIMITS = { avatar: 256, icon: 256, banner: 1280 } as const; /** * Resize a profile image (avatar, icon, or banner) to the target max dimension. * Preserves animated GIF frames. No-op if the image is already small enough. * Writes to a temp file then atomically renames to avoid corruption. */ export async function resizeProfileImage( filepath: string, type: 'avatar' | 'icon' | 'banner', ): Promise { const maxDim = PROFILE_LIMITS[type]; try { const image = sharp(filepath, { animated: true }); const metadata = await image.metadata(); if (!metadata.width || metadata.width <= maxDim) return; const tmpPath = filepath + '.tmp'; await sharp(filepath, { animated: true }) .resize({ width: maxDim, withoutEnlargement: true }) .toFile(tmpPath); fs.renameSync(tmpPath, filepath); } catch (err) { // Non-fatal — the original file is still intact console.error(`Profile image resize failed (non-fatal) for ${path.basename(filepath)}:`, err); // Clean up temp file if it was partially written try { fs.unlinkSync(filepath + '.tmp'); } catch { /* ignore */ } } } const RESIZABLE_MIMETYPES = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif', 'image/tiff', ]); const THUMBNAIL_MAX_WIDTH = 800; const THUMBNAIL_QUALITY = 80; /** * Derive a deterministic thumbnail filename from the original. * e.g. "123456789.png" → "123456789_thumb.webp" */ export function thumbFilename(original: string): string { const parsed = path.parse(original); return `${parsed.name}_thumb.webp`; } /** Check if the given mimetype is a resizable image format. */ export function isResizableImage(mimetype: string): boolean { return RESIZABLE_MIMETYPES.has(mimetype); } /** * Generate a WebP thumbnail for the given image file. * Returns the thumbnail filename on success, or null if: * - The image is already ≤ THUMBNAIL_MAX_WIDTH px wide * - An error occurs (upload still succeeds without thumbnail) */ export async function generateThumbnail( originalPath: string, mimetype: string, uploadDir: string, ): Promise { if (!isResizableImage(mimetype)) return null; try { const image = sharp(originalPath); const metadata = await image.metadata(); // Skip if the original is already small enough if (!metadata.width || metadata.width <= THUMBNAIL_MAX_WIDTH) { return null; } // Skip animated images — Sharp would flatten to a single static frame if (metadata.pages && metadata.pages > 1) { return null; } const originalFilename = path.basename(originalPath); const thumbName = thumbFilename(originalFilename); const thumbPath = path.join(uploadDir, thumbName); await sharp(originalPath) .resize({ width: THUMBNAIL_MAX_WIDTH, withoutEnlargement: true }) .webp({ quality: THUMBNAIL_QUALITY }) .toFile(thumbPath); return thumbName; } catch (err) { console.error('Thumbnail generation failed (non-fatal):', err); return null; } } /** Check if ffmpeg/ffprobe are available on the system PATH. Caches result. */ export async function checkFfmpeg(): Promise { if (ffmpegAvailable !== null) return ffmpegAvailable; try { await execFile('ffprobe', ['-version'], { timeout: 5000 }); ffmpegAvailable = true; } catch { console.warn('ffmpeg/ffprobe not found — video thumbnails and metadata extraction disabled'); ffmpegAvailable = false; } return ffmpegAvailable; } /** Extract width/height from an image file using sharp. Works for all formats including animated GIFs and small images. */ export async function probeImageDimensions(filepath: string): Promise<{ width: number; height: number } | null> { try { const metadata = await sharp(filepath).metadata(); if (metadata.width && metadata.height) { return { width: metadata.width, height: metadata.height }; } return null; } catch { return null; } } /** Extract dimensions and/or duration from a video or audio file using ffprobe. */ export async function probeMediaMeta( filepath: string, mimetype: string, ): Promise<{ width?: number; height?: number; duration?: number } | null> { if (!(await checkFfmpeg())) return null; try { const result: { width?: number; height?: number; duration?: number } = {}; // Video dimensions (stream-level) if (mimetype.startsWith('video/')) { try { const { stdout } = await execFile('ffprobe', [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', '-i', filepath, ], { timeout: FFMPEG_TIMEOUT }); const data = JSON.parse(stdout); const stream = data?.streams?.[0]; if (stream?.width && stream?.height) { result.width = stream.width; result.height = stream.height; } } catch { /* dimension probe failed — continue for duration */ } } // Duration (format-level — works for video and audio) try { const { stdout } = await execFile('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'json', '-i', filepath, ], { timeout: FFMPEG_TIMEOUT }); const data = JSON.parse(stdout); const dur = parseFloat(data?.format?.duration); if (Number.isFinite(dur)) { result.duration = Math.round(dur * 100) / 100; // 2 decimal places } } catch { /* duration probe failed */ } return Object.keys(result).length > 0 ? result : null; } catch { return null; } } /** * Extract a single frame from a video and create a WebP thumbnail. * Returns thumbnail filename + rotation-corrected dimensions, or null on error. * Uses -ss before -i for fast keyframe seeking. * ffmpeg auto-rotation is on by default, so portrait mobile videos produce correctly oriented frames. */ export async function generateVideoThumbnail( filepath: string, uploadDir: string, ): Promise<{ thumbnailFilename: string; width: number; height: number } | null> { if (!(await checkFfmpeg())) return null; try { // Try extracting frame at 1 second, fall back to 0 for short videos let frameBuffer: Buffer | null = null; for (const seekTime of ['1', '0']) { try { const { stdout } = await execFile('ffmpeg', [ '-ss', seekTime, '-i', filepath, '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-', ], { timeout: FFMPEG_TIMEOUT, encoding: 'buffer', maxBuffer: 50 * 1024 * 1024 }); if (stdout && stdout.length > 0) { frameBuffer = stdout as unknown as Buffer; break; } } catch (err) { if (seekTime === '1') continue; // Try 0 next throw err; } } if (!frameBuffer) return null; // Read the frame's actual dimensions (rotation-corrected by ffmpeg) const frameImage = sharp(frameBuffer); const frameMeta = await frameImage.metadata(); if (!frameMeta.width || !frameMeta.height) return null; const originalWidth = frameMeta.width; const originalHeight = frameMeta.height; // Generate WebP thumbnail (same settings as image thumbnails) const originalFilename = path.basename(filepath); const thumbName = thumbFilename(originalFilename); const thumbPath = path.join(uploadDir, thumbName); await sharp(frameBuffer) .resize({ width: THUMBNAIL_MAX_WIDTH, withoutEnlargement: true }) .webp({ quality: THUMBNAIL_QUALITY }) .toFile(thumbPath); return { thumbnailFilename: thumbName, width: originalWidth, height: originalHeight }; } catch (err) { console.error('Video thumbnail generation failed (non-fatal):', err); return null; } }