import sharp from 'sharp'; import fs from 'fs'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { absolutePath, ensureDir } from './storage.js'; const execFileAsync = promisify(execFile); export interface ImageMeta { width: number; height: number; mimeType: string; size: number; } export async function extractMeta(filePath: string): Promise { const abs = absolutePath(filePath); const meta = await sharp(abs).metadata(); const stat = fs.statSync(abs); const mimeMap: Record = { jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', }; return { width: meta.width ?? 0, height: meta.height ?? 0, mimeType: mimeMap[meta.format ?? ''] ?? 'image/jpeg', size: stat.size, }; } export async function extractVideoMeta(filePath: string, mimeType: string): Promise { const abs = absolutePath(filePath); const stat = fs.statSync(abs); try { const { stdout } = await execFileAsync('ffprobe', [ '-v', 'quiet', '-print_format', 'json', '-show_streams', abs, ]); const data = JSON.parse(stdout); const video = (data.streams as any[])?.find((s) => s.codec_type === 'video'); return { width: video?.width ?? 1280, height: video?.height ?? 720, mimeType, size: stat.size, }; } catch { // ffprobe unavailable or failed — use safe defaults return { width: 1280, height: 720, mimeType, size: stat.size }; } } export async function saveBuffer(buffer: Buffer, destRelPath: string): Promise { ensureDir(destRelPath); const abs = absolutePath(destRelPath); fs.writeFileSync(abs, buffer); } export interface ResizeOptions { width?: number; height?: number; quality?: number; } export async function resizeImage( srcRelPath: string, destRelPath: string, options: ResizeOptions ): Promise { const srcAbs = absolutePath(srcRelPath); ensureDir(destRelPath); const destAbs = absolutePath(destRelPath); const src = await sharp(srcAbs).metadata(); const isGif = src.format === 'gif'; let pipeline = sharp(srcAbs, { animated: isGif }); if (options.width || options.height) { pipeline = pipeline.resize({ width: options.width, height: options.height, fit: 'inside', withoutEnlargement: true, }); } if (!isGif && options.quality) { if (src.format === 'jpeg') pipeline = pipeline.jpeg({ quality: options.quality }); else if (src.format === 'png') pipeline = pipeline.png({ quality: options.quality }); else if (src.format === 'webp') pipeline = pipeline.webp({ quality: options.quality }); } await pipeline.toFile(destAbs); const resultMeta = await sharp(destAbs).metadata(); const stat = fs.statSync(destAbs); const mimeMap: Record = { jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', }; return { width: resultMeta.width ?? 0, height: resultMeta.height ?? 0, mimeType: mimeMap[resultMeta.format ?? ''] ?? 'image/jpeg', size: stat.size, }; }