const sharp = require('sharp') const fs = require('fs-extra') const path = require('path') // اندازه/کیفیتِ نمایش — نه سایزِ چاپ؛ کاربر روی موبایل/وب حداکثر تا این عرض می‌بینه، // پس آپلودِ خامِ دوربین (معمولاً ۳۰۰۰-۴۰۰۰ پیکسل) فقط مصرفِ اینترنت رو چند برابر می‌کنه // بدون هیچ بهبودِ بصری. این مقادیر برای محتوای پست/استوری تنظیم شده‌اند. const DEFAULT_MAX_DIMENSION = 1600 const DEFAULT_JPEG_QUALITY = 82 /** * تصویرِ رویِ دیسک را (در صورت لازم) کوچک و فشرده می‌کند — همون فایل با همون * پسوند/فرمت بازنویسی می‌شود تا Content-Type و مسیرهای ذخیره‌شده در دیتابیس تغییر نکنند. * اگر فایلِ فشرده‌شده کوچک‌تر نبود (مثلاً عکسِ کوچک/از قبل فشرده)، فایلِ اصلی نگه داشته می‌شود. */ async function optimizeImageFile(filePath, { maxDimension = DEFAULT_MAX_DIMENSION, jpegQuality = DEFAULT_JPEG_QUALITY } = {}) { if (!filePath) return let tempPath = null try { if (!(await fs.pathExists(filePath))) return const ext = path.extname(filePath).toLowerCase() const metadata = await sharp(filePath, { failOn: 'none' }).metadata() if (!metadata.width || !metadata.height) return // فرمتِ ناشناس برای sharp (مثلاً gif متحرک) — دست‌نخورده می‌ماند const needsResize = metadata.width > maxDimension || metadata.height > maxDimension let pipeline = sharp(filePath, { failOn: 'none' }).rotate() // rotate() بدون آرگومان: EXIF orientation را اعمال و حذف می‌کند if (needsResize) { pipeline = pipeline.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true }) } if (ext === '.png') { pipeline = pipeline.png({ compressionLevel: 9, palette: true }) } else if (ext === '.webp') { pipeline = pipeline.webp({ quality: jpegQuality }) } else { // .jpg/.jpeg و هر پسوندِ دیگرِ عکس (heic و مشابه، اگر sharp بتواند دیکد کند) pipeline = pipeline.jpeg({ quality: jpegQuality, mozjpeg: true }) } tempPath = path.join(path.dirname(filePath), `optimize-${Date.now()}-${path.basename(filePath)}`) await pipeline.toFile(tempPath) const [originalStat, optimizedStat] = await Promise.all([fs.stat(filePath), fs.stat(tempPath)]) if (optimizedStat.size >= originalStat.size) { await fs.remove(tempPath) return } await fs.rename(tempPath, filePath) console.log(`[imageOptimize] ${path.basename(filePath)}: ${originalStat.size} -> ${optimizedStat.size} bytes`) } catch (error) { console.error('[imageOptimize] failed:', error.message) if (tempPath) await fs.remove(tempPath).catch(() => {}) } } module.exports = { optimizeImageFile, DEFAULT_MAX_DIMENSION, DEFAULT_JPEG_QUALITY } const VARIANT_WIDTHS = [480, 800] /** * نسخه‌های کوچک‌تر (WebP، با حفظ نسبت تصویر) کنار فایل اصلی می‌سازد و یک placeholder * خیلی کم‌حجم هم می‌سازد. فایل اصلی دست‌نخورده می‌ماند و نسخه‌ای که از اصل بزرگ‌تر * نباشد ساخته نمی‌شود. */ async function generateImageVariants(filePath, { widths = VARIANT_WIDTHS } = {}) { if (!filePath) return try { if (!(await fs.pathExists(filePath))) return const metadata = await sharp(filePath, { failOn: 'none' }).metadata() if (!metadata.width || !metadata.height) return const dir = path.dirname(filePath) const base = path.basename(filePath, path.extname(filePath)) const source = sharp(filePath, { failOn: 'none' }).rotate() for (const width of widths) { if (width >= metadata.width) continue await source .clone() .resize({ width, fit: 'inside', withoutEnlargement: true }) .webp({ quality: 80 }) .toFile(path.join(dir, `${base}.w${width}.webp`)) } await source .clone() .resize({ width: 24, fit: 'inside', withoutEnlargement: true }) .webp({ quality: 40 }) .toFile(path.join(dir, `${base}.lqip.webp`)) } catch (error) { console.error('[imageOptimize] variants failed:', error.message) } } module.exports.generateImageVariants = generateImageVariants