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:
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
64
index.js
64
index.js
@@ -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
69
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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