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:
payacom
2026-09-15 17:55:09 +03:30
parent 26979057d7
commit e1abb40cc6
14 changed files with 316 additions and 27 deletions

View File

@@ -7,6 +7,13 @@ const ChatRoomModel = require('../../../models/ChatRoomModel')
const ChatRoomMessageModel = require('../../../models/ChatRoomMessageModel')
const UserModel = require('../../../models/UserModel')
const { ProvinceModel, CityModel } = require('../../../models/StateCity')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[chatRoomController] postVideoProcessing unavailable:', error.message)
}
const {
slugifyTitle,
userCanSeeRoom,
@@ -227,6 +234,7 @@ const createRoom = async (req, res, next) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(imageFile.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(imageFile.path, filePath)
await optimizeImageFile(filePath, { maxDimension: 800 })
imagePath = `/chat-rooms/${uniqueFileName}`
}
@@ -397,6 +405,13 @@ const sendRoomMessage = async (req, res, next) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(uploaded.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(uploaded.path, filePath)
if (resolvedFileType === 'image' || (uploaded.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (uploaded.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[chatRoomController] video optimize background error:', err.message)
})
}
fileUrl = `/chat-rooms/messages/${uniqueFileName}`
}

View File

@@ -30,6 +30,7 @@ try {
} catch (error) {
console.warn('[postController] postVideoProcessing unavailable:', error.message)
}
const { optimizeImageFile } = require('../../../utils/imageOptimize')
const createPostValidationRules = () => {
return [
@@ -89,6 +90,14 @@ const createPost = async (req, res) => {
? maskPhoneNumbers(String(caption).trim().slice(0, 2000))
: null
// کوچک/فشرده‌کردنِ عکس‌ها قبل از ذخیره — قبل از اینکه مسیرشون توی دیتابیس بره، چون
// مسیر (file.path) دست‌نخورده می‌ماند، فقط بایت‌های خودِ فایل جایگزین می‌شوند
await Promise.all(
files
.filter((file) => (file.mimetype || file.type || '').startsWith('image/'))
.map((file) => optimizeImageFile(file.path))
)
const filePaths = files.map((file) => ({
path: file.path,
type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image'

View File

@@ -6,6 +6,13 @@ const crypto = require('crypto')
const ShopModel = require('../../../models/ShopModel')
const ShopChatMessageModel = require('../../../models/ShopChatMessageModel')
const UserModel = require('../../../models/UserModel')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[shopChatController] postVideoProcessing unavailable:', error.message)
}
function chatError (res, status, message) {
return res.status(status).json({ error: true, message })
@@ -235,6 +242,13 @@ const sendShopChatFileMessage = async (req, res, next) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath, { overwrite: true })
if (fileType === 'image') {
await optimizeImageFile(filePath)
} else if (fileType === 'video') {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[shopChatController] video optimize background error:', err.message)
})
}
const fileUrl = `/shop-chat/${uniqueFileName}`
const message = await ShopChatMessageModel.create({

View File

@@ -19,6 +19,7 @@ const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers'
const { viewerIsBlockedBy } = require('../../../utils/blockVisibility')
const ShopCategoryModel = require('../../../models/ShopCategoryModel')
const { buildUnifiedInterestScore } = require('../../../utils/exploreAlgorithm')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
/** Best-effort JWT decode for public browsing endpoints — never throws, never blocks guests. */
function getOptionalUserId (req) {
@@ -42,6 +43,9 @@ async function saveFile (file, folder, prefix) {
const uniqueFileName = `${prefix}-${Date.now()}${Math.random().toString().slice(2, 9)}${path.extname(file.name)}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath, { overwrite: true })
if ((file.mimetype || file.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
}
return `/${folder}/${uniqueFileName}`
}

View File

@@ -8,6 +8,7 @@ const ShopRatingModel = require('../../../models/ShopRatingModel')
const ShopFollowModel = require('../../../models/ShopFollowModel')
const OrderModel = require('../../../models/OrderModel')
const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
const MS_DAY = 24 * 60 * 60 * 1000
const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null }
@@ -286,6 +287,7 @@ const updateShopLogo = async (req, res, next) => {
const uniqueFileName = `${shop._id}-${Date.now()}${path.extname(logo.name)}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(logo.path, filePath, { overwrite: true })
await optimizeImageFile(filePath, { maxDimension: 800 })
shop.logo = `/shops/${uniqueFileName}`
await shop.save()

View File

@@ -13,6 +13,17 @@ const { filterStoryUserIdsByPrivateProfile } = require('../../../utils/privatePr
const { sendStoryReactionMessage } = require('../../../utils/storyChatMessage')
const { enrichMessage, emitChatEvents } = require('../../../helpers/chatHelpers')
const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
let optimizeVideoFaststart = async () => {}
try {
({ optimizeVideoFaststart } = require('../../../utils/postVideoProcessing'))
} catch (error) {
console.warn('[storyController] postVideoProcessing unavailable:', error.message)
}
// استوری روی صفحه‌ی کامل موبایل دیده می‌شه، ولی پهنا/ارتفاعِ خامِ دوربین (اغلب بیشتر از
// عرضِ صفحه‌ست) لازم نیست دست‌نخورده بمونه
const STORY_MAX_DIMENSION = 1440
const { STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter } = require('../../../utils/storyPhotoFilters')
const LINK_OVERLAY_KINDS = ['pkg', 'project', 'billboard', 'shop', 'shopproduct', 'booking']
@@ -212,6 +223,13 @@ const createStoryBase64 = async (req, res) => {
const filename = `${Date.now()}-${userId}${ext}`
const filePath = path.join(storyStorageDir, filename)
await fs.promises.writeFile(filePath, buffer)
if (mediaType === 'image') {
await optimizeImageFile(filePath, { maxDimension: STORY_MAX_DIMENSION })
} else {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[storyController] video optimize background error:', err.message)
})
}
const story = await StoryModel.create({
user_id: userId,
@@ -243,6 +261,13 @@ const createStoryMultipart = async (req, res) => {
}
const mediaType = (file.mimetype || '').startsWith('video/') ? 'video' : 'image'
if (mediaType === 'image') {
await optimizeImageFile(file.path, { maxDimension: STORY_MAX_DIMENSION })
} else {
optimizeVideoFaststart(file.path).catch((err) => {
console.error('[storyController] video optimize background error:', err.message)
})
}
let overlays = []
try {

View File

@@ -3,6 +3,11 @@ const fs = require('fs-extra')
const path = require('path')
const UserModel = require('../../../models/UserModel')
const jwt = require('jsonwebtoken')
const { optimizeImageFile } = require('../../../utils/imageOptimize')
// آواتار هیچ‌وقت بزرگ‌تر از این نمایش داده نمی‌شود — عکسِ خامِ دوربین (چند مگابایت) رو
// برای یه دایره‌ی کوچیک نگه‌داشتن فقط مصرفِ اینترنت رو بالا می‌بره
const AVATAR_MAX_DIMENSION = 800
const setProfileImage = async (req, res, next) => {
try {
@@ -34,6 +39,7 @@ const setProfileImage = async (req, res, next) => {
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(profile_image.path, filePath)
await optimizeImageFile(filePath, { maxDimension: AVATAR_MAX_DIMENSION })
// ذخیره مسیر فایل در دیتابیس
user.profile_image = `/profiles/${uniqueFileName}`
await user.save()
@@ -81,6 +87,7 @@ const updateProfileImage = async (req, res, next) => {
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(profile_image.path, filePath)
await optimizeImageFile(filePath, { maxDimension: AVATAR_MAX_DIMENSION })
user.profile_image = `/profiles/${uniqueFileName}`
await user.save()

View File

@@ -1,8 +1,16 @@
require('dotenv').config();
const express = require('express');
const app = express();
const compression = require('compression');
const path = require('path');
const crypto = require('crypto');
const { optimizeImageFile } = require('./utils/imageOptimize');
let optimizeVideoFaststart = async () => {};
try {
({ optimizeVideoFaststart } = require('./utils/postVideoProcessing'));
} catch (error) {
console.warn('[index] postVideoProcessing unavailable:', error.message);
}
const http = require('http').createServer(app);
const cors = require('cors');
const jwt = require('jsonwebtoken');
@@ -106,30 +114,41 @@ const io = require('socket.io')(http, {
app.set('io', io);
// gzip/brotli فقط برای پاسخ‌های متنی (JSON فید/اکسپلور/چت) — فیلترِ پیش‌فرضِ compression
// خودش مدیا (image/*، video/*) رو compressible نمی‌دونه و رد می‌کنه، پس نیازی به فیلترِ
// دستی نیست؛ فقط باید قبل از هر res.json/res.send دیگه‌ای بیاد
app.use(compression());
// افزایش limit برای Base64 (1000mb کافیه)
app.use(express.json({ limit: '1000mb' }));
app.use(express.urlencoded({ limit: '1000mb', extended: true }));
// کش طولانی برای مدیا — بدون این، مرورگر/اپ هر بار همون عکس/ویدیوی قبلی رو دوباره
// دانلود می‌کرد. immutable نمی‌ذاریم چون مسیر آپلود تازه (optimizeImageFile/
// optimizeVideoFaststart) گاهی چند ثانیه بعدِ همون ریسپانس، بایت‌های همون فایل رو در جا
// عوض می‌کنه؛ بدونِ immutable مرورگر با ETag/Last-Modified همچنان رواعتبارسنجی می‌کنه
const STATIC_MEDIA_OPTIONS = { maxAge: '365d' };
// مسیرهای مشخص قبل از /storage عمومی — پروفایل از هر دو محل (قدیم و جدید) سرو می‌شود
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles')));
app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles')));
app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts')));
app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts')));
app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts')));
app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts')));
app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages')));
app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages')));
app.use('/storage/shop-chat', express.static(path.join(__dirname, '../storage/shop-chat')));
app.use('/storage/shop-chat', express.static(path.join(__dirname, 'storage/shop-chat')));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, '../storage/chat-rooms')));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, 'storage/chat-rooms')));
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets')));
app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets')));
app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising')));
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising')));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services')));
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services')));
app.use('/storage', express.static(path.join(__dirname, 'storage')));
app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles'), STATIC_MEDIA_OPTIONS));
app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles'), STATIC_MEDIA_OPTIONS));
app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts'), STATIC_MEDIA_OPTIONS));
app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages'), STATIC_MEDIA_OPTIONS));
app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages'), STATIC_MEDIA_OPTIONS));
app.use('/storage/shop-chat', express.static(path.join(__dirname, '../storage/shop-chat'), STATIC_MEDIA_OPTIONS));
app.use('/storage/shop-chat', express.static(path.join(__dirname, 'storage/shop-chat'), STATIC_MEDIA_OPTIONS));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, '../storage/chat-rooms'), STATIC_MEDIA_OPTIONS));
app.use('/storage/chat-rooms', express.static(path.join(__dirname, 'storage/chat-rooms'), STATIC_MEDIA_OPTIONS));
app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets'), STATIC_MEDIA_OPTIONS));
app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets'), STATIC_MEDIA_OPTIONS));
app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'), STATIC_MEDIA_OPTIONS));
app.use('/storage', express.static(path.join(__dirname, 'storage'), STATIC_MEDIA_OPTIONS));
require('./boot');
@@ -394,6 +413,13 @@ app.post('/api/v1/chat/file', [blockCheck, chatPeerBlock], async (req, res) => {
const uniqueFileName = `${Date.now()}-${crypto.randomUUID()}${path.extname(file.name || '')}`
const filePath = path.join(uploadDir, uniqueFileName)
await fs.move(file.path, filePath)
if (resolvedFileType === 'image' || (file.type || '').startsWith('image/')) {
await optimizeImageFile(filePath)
} else if (resolvedFileType === 'video' || (file.type || '').startsWith('video/')) {
optimizeVideoFaststart(filePath).catch((err) => {
console.error('[chat/file] video optimize background error:', err.message)
})
}
fileUrl = `/messages/${uniqueFileName}`
}

69
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"axios": "^1.6.7",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"compression": "^1.8.2",
"connect-redis": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
@@ -2569,6 +2570,74 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://mirror-npm.runflare.com/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.2",
"resolved": "https://mirror-npm.runflare.com/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://mirror-npm.runflare.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://mirror-npm.runflare.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://mirror-npm.runflare.com/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression/node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://mirror-npm.runflare.com/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"axios": "^1.6.7",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"compression": "^1.8.2",
"connect-redis": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",

View File

@@ -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
View 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 }

View File

@@ -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);
}
}

View File

@@ -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 }