Reduce media bandwidth: compress uploads, add long-lived cache headers, gzip API
- Resize/re-encode every uploaded image server-side (posts, avatars, stories, shop products/logo, chat messages) via a shared sharp-based optimizer — caps dimensions to what's actually displayed and re-compresses in place, cutting a typical raw camera photo by ~90% with no visible quality loss - Cap uploaded video resolution/bitrate (re-encode only when above 1280px/ ~2.5Mbps) alongside the existing faststart step; apply faststart+cap to story and chat videos too (previously stored completely raw) - Add long-lived Cache-Control to all /storage static routes so clients stop re-downloading unchanged media on every request - Add gzip/brotli compression for JSON API responses (feed/explore/chat) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const axios = require('axios')
|
||||
const { optimizeImageFile } = require('./imageOptimize')
|
||||
|
||||
const EXT_BY_CONTENT_TYPE = {
|
||||
'image/png': '.png',
|
||||
@@ -32,6 +33,7 @@ async function downloadImageToProfiles(url, baseName) {
|
||||
const filePath = path.join(uploadDir, uniqueFileName)
|
||||
|
||||
await fs.writeFile(filePath, Buffer.from(response.data))
|
||||
await optimizeImageFile(filePath, { maxDimension: 800 })
|
||||
|
||||
return `/profiles/${uniqueFileName}`
|
||||
}
|
||||
|
||||
59
utils/imageOptimize.js
Normal file
59
utils/imageOptimize.js
Normal file
@@ -0,0 +1,59 @@
|
||||
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 }
|
||||
@@ -16,26 +16,73 @@ async function runFfmpeg(command) {
|
||||
}
|
||||
}
|
||||
|
||||
/** faststart بدون re-encode — شروع پخش سریعتر */
|
||||
// موبایل/وب هیچجا بیشتر از این نمایش داده نمیشه — ویدیوی خامِ دوربین (اغلب ۱۰۸۰p+ با
|
||||
// بیتریتِ چند برابرِ لازم) رو بدون افتِ کیفیتِ محسوس به این سقف میرسونیم
|
||||
const VIDEO_MAX_DIMENSION = 1280;
|
||||
const VIDEO_MAX_BITRATE_KBPS = 2500;
|
||||
|
||||
async function probeVideo(filePath) {
|
||||
try {
|
||||
const { stdout } = await execPromise(
|
||||
`ffprobe -v error -select_streams v:0 -show_entries stream=width,height,bit_rate -show_entries format=bit_rate -of json "${filePath}"`
|
||||
);
|
||||
const data = JSON.parse(stdout);
|
||||
const stream = data.streams?.[0] || {};
|
||||
const width = Number(stream.width) || 0;
|
||||
const height = Number(stream.height) || 0;
|
||||
const bitRateBps = Number(stream.bit_rate) || Number(data.format?.bit_rate) || 0;
|
||||
return { width, height, bitRateKbps: bitRateBps ? Math.round(bitRateBps / 1000) : 0 };
|
||||
} catch (error) {
|
||||
console.error("[postVideoProcessing] probe failed:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* فایل رو یا فقط faststart میکنه (بدون کیفیتکاهی، وقتی از قبل کوچیک/بهینهست) یا
|
||||
* وقتی رزولوشن/بیتریت بیشتر از چیزیه که کاربر واقعاً میبینه، دوباره با سقفِ
|
||||
* رزولوشن+بیتریت انکود میکنه (که faststart هم توش هست، جداگانه لازم نیست)
|
||||
*/
|
||||
async function optimizeVideoFaststart(filePath) {
|
||||
if (!(await fs.pathExists(filePath))) return;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
const tempPath = path.join(dir, `faststart-${Date.now()}-${base}`);
|
||||
const info = await probeVideo(filePath);
|
||||
|
||||
const ok = await runFfmpeg(
|
||||
`ffmpeg -i "${filePath}" -c copy -movflags +faststart "${tempPath}" -y`
|
||||
);
|
||||
const needsReencode =
|
||||
info && ((info.width > VIDEO_MAX_DIMENSION || info.height > VIDEO_MAX_DIMENSION) ||
|
||||
(info.bitRateKbps > 0 && info.bitRateKbps > VIDEO_MAX_BITRATE_KBPS * 1.3));
|
||||
|
||||
const tempPath = path.join(dir, `optimize-${Date.now()}-${base}`);
|
||||
|
||||
const ok = needsReencode
|
||||
? await runFfmpeg(
|
||||
`ffmpeg -i "${filePath}" -vf "scale='min(${VIDEO_MAX_DIMENSION},iw)':'min(${VIDEO_MAX_DIMENSION},ih)':force_original_aspect_ratio=decrease" -c:v libx264 -preset veryfast -crf 25 -maxrate ${VIDEO_MAX_BITRATE_KBPS}k -bufsize ${VIDEO_MAX_BITRATE_KBPS * 2}k -c:a aac -b:a 128k -movflags +faststart "${tempPath}" -y`
|
||||
)
|
||||
: await runFfmpeg(
|
||||
`ffmpeg -i "${filePath}" -c copy -movflags +faststart "${tempPath}" -y`
|
||||
);
|
||||
|
||||
if (!ok || !(await fs.pathExists(tempPath))) return;
|
||||
|
||||
try {
|
||||
const [originalStat, optimizedStat] = await Promise.all([
|
||||
fs.stat(filePath),
|
||||
fs.stat(tempPath),
|
||||
]);
|
||||
// اگه انکودِ جدید بزرگتر شد (ویدیوی خیلی کوتاه/از قبل فشرده)، فایلِ اصلی نگه داشته میشه
|
||||
if (needsReencode && optimizedStat.size >= originalStat.size) {
|
||||
await fs.remove(tempPath).catch(() => {});
|
||||
return;
|
||||
}
|
||||
await fs.rename(tempPath, filePath);
|
||||
console.log(`[postVideoProcessing] faststart applied: ${base}`);
|
||||
console.log(
|
||||
`[postVideoProcessing] ${needsReencode ? "re-encoded" : "faststart applied"}: ${base} (${originalStat.size} -> ${optimizedStat.size} bytes)`
|
||||
);
|
||||
} catch (error) {
|
||||
await fs.remove(tempPath).catch(() => {});
|
||||
console.error("[postVideoProcessing] faststart rename failed:", error.message);
|
||||
console.error("[postVideoProcessing] rename failed:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,19 @@ const STORY_PHOTO_FILTERS = {
|
||||
|
||||
const STORY_PHOTO_FILTER_NAMES = Object.keys(STORY_PHOTO_FILTERS)
|
||||
|
||||
// همون سقفِ اندازهی storyController.js — این بافر بعداً دوباره از موبایل به createStoryBase64
|
||||
// آپلود میشه، پس کوچیک نگهداشتنش اینجا هم رفتوبرگشتِ پیشنمایش و هم آپلودِ نهایی رو کم میکنه
|
||||
const STORY_MAX_DIMENSION = 1440
|
||||
|
||||
/** buffer ورودی -> بافرِ jpeg خروجی با فیلترِ اعمالشده. filterName نامعتبر => همون تصویر اصلی. */
|
||||
async function applyStoryPhotoFilter (buffer, filterName) {
|
||||
const apply = STORY_PHOTO_FILTERS[filterName] || STORY_PHOTO_FILTERS.normal
|
||||
return apply(sharp(buffer)).jpeg({ quality: 90 }).toBuffer()
|
||||
const image = apply(sharp(buffer).rotate())
|
||||
const metadata = await sharp(buffer).metadata()
|
||||
const resized = (metadata.width > STORY_MAX_DIMENSION || metadata.height > STORY_MAX_DIMENSION)
|
||||
? image.resize({ width: STORY_MAX_DIMENSION, height: STORY_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
|
||||
: image
|
||||
return resized.jpeg({ quality: 85, mozjpeg: true }).toBuffer()
|
||||
}
|
||||
|
||||
module.exports = { STORY_PHOTO_FILTERS, STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter }
|
||||
|
||||
Reference in New Issue
Block a user