diff --git a/controllers/application/chatRooms/chatRoomController.js b/controllers/application/chatRooms/chatRoomController.js index 0b55296..e6587fa 100644 --- a/controllers/application/chatRooms/chatRoomController.js +++ b/controllers/application/chatRooms/chatRoomController.js @@ -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}` } diff --git a/controllers/application/posts/postController.js b/controllers/application/posts/postController.js index 6fa3e95..1be3e25 100644 --- a/controllers/application/posts/postController.js +++ b/controllers/application/posts/postController.js @@ -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' diff --git a/controllers/application/shop-chat/shopChatController.js b/controllers/application/shop-chat/shopChatController.js index 69fa173..7b9d158 100644 --- a/controllers/application/shop-chat/shopChatController.js +++ b/controllers/application/shop-chat/shopChatController.js @@ -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({ diff --git a/controllers/application/shop-products/shopProductController.js b/controllers/application/shop-products/shopProductController.js index e012796..aac6dee 100644 --- a/controllers/application/shop-products/shopProductController.js +++ b/controllers/application/shop-products/shopProductController.js @@ -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}` } diff --git a/controllers/application/shops/shopController.js b/controllers/application/shops/shopController.js index d6bfe1e..08df933 100644 --- a/controllers/application/shops/shopController.js +++ b/controllers/application/shops/shopController.js @@ -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() diff --git a/controllers/application/stories/storyController.js b/controllers/application/stories/storyController.js index 7d6bb9b..cb2e5ea 100644 --- a/controllers/application/stories/storyController.js +++ b/controllers/application/stories/storyController.js @@ -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 { diff --git a/controllers/application/verify/setProfileImageController.js b/controllers/application/verify/setProfileImageController.js index 365da48..0f55692 100644 --- a/controllers/application/verify/setProfileImageController.js +++ b/controllers/application/verify/setProfileImageController.js @@ -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() diff --git a/index.js b/index.js index 1767143..ad33434 100644 --- a/index.js +++ b/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}` } diff --git a/package-lock.json b/package-lock.json index 015d12f..9aac1ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a90c028..c3e267f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/utils/downloadRemoteImage.js b/utils/downloadRemoteImage.js index 90455ad..297c45e 100644 --- a/utils/downloadRemoteImage.js +++ b/utils/downloadRemoteImage.js @@ -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}` } diff --git a/utils/imageOptimize.js b/utils/imageOptimize.js new file mode 100644 index 0000000..524e9ba --- /dev/null +++ b/utils/imageOptimize.js @@ -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 } diff --git a/utils/postVideoProcessing.js b/utils/postVideoProcessing.js index 2dc8dc1..ff05c7c 100644 --- a/utils/postVideoProcessing.js +++ b/utils/postVideoProcessing.js @@ -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); } } diff --git a/utils/storyPhotoFilters.js b/utils/storyPhotoFilters.js index 45e472d..70f9337 100644 --- a/utils/storyPhotoFilters.js +++ b/utils/storyPhotoFilters.js @@ -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 }