99 lines
4.5 KiB
JavaScript
99 lines
4.5 KiB
JavaScript
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
|