- 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>
146 lines
5.4 KiB
JavaScript
146 lines
5.4 KiB
JavaScript
const fs = require("fs-extra");
|
||
const path = require("path");
|
||
const { exec } = require("child_process");
|
||
const util = require("util");
|
||
const PostModel = require("../models/PostModel");
|
||
|
||
const execPromise = util.promisify(exec);
|
||
|
||
async function runFfmpeg(command) {
|
||
try {
|
||
await execPromise(command);
|
||
return true;
|
||
} catch (error) {
|
||
console.error("[postVideoProcessing]", error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// موبایل/وب هیچجا بیشتر از این نمایش داده نمیشه — ویدیوی خامِ دوربین (اغلب ۱۰۸۰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 info = await probeVideo(filePath);
|
||
|
||
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] ${needsReencode ? "re-encoded" : "faststart applied"}: ${base} (${originalStat.size} -> ${optimizedStat.size} bytes)`
|
||
);
|
||
} catch (error) {
|
||
await fs.remove(tempPath).catch(() => {});
|
||
console.error("[postVideoProcessing] rename failed:", error.message);
|
||
}
|
||
}
|
||
|
||
/** thumbnail برای poster در گرید و ریلز */
|
||
async function generateVideoThumbnail(filePath, postId) {
|
||
if (!(await fs.pathExists(filePath))) return null;
|
||
|
||
const thumbDir = path.join(path.dirname(filePath), "..", "thumbnails");
|
||
await fs.ensureDir(thumbDir);
|
||
|
||
const thumbName = `${postId}.jpg`;
|
||
const thumbPath = path.join(thumbDir, thumbName);
|
||
|
||
const ok = await runFfmpeg(
|
||
`ffmpeg -ss 00:00:00.5 -i "${filePath}" -vframes 1 -q:v 3 -y "${thumbPath}"`
|
||
);
|
||
|
||
if (!ok || !(await fs.pathExists(thumbPath))) return null;
|
||
|
||
// مسیر نسبی قابل سرو از /storage/posts/thumbnails
|
||
return `/posts/thumbnails/${thumbName}`;
|
||
}
|
||
|
||
async function processPostVideoInBackground(filePath, postId) {
|
||
if (!filePath || !postId) return;
|
||
|
||
const existing = await PostModel.findById(postId).select('files post_images').lean();
|
||
const hasCustomCover =
|
||
existing?.files?.some((file) => file.type === 'image') ||
|
||
(existing?.post_images?.length ?? 0) > 0;
|
||
if (hasCustomCover) {
|
||
console.log(`[postVideoProcessing] skip thumbnail (custom cover) post=${postId}`);
|
||
await optimizeVideoFaststart(filePath);
|
||
return;
|
||
}
|
||
|
||
console.log(`[postVideoProcessing] start post=${postId}`);
|
||
|
||
await optimizeVideoFaststart(filePath);
|
||
|
||
const relativeThumb = await generateVideoThumbnail(filePath, postId);
|
||
if (!relativeThumb) return;
|
||
|
||
try {
|
||
await PostModel.findByIdAndUpdate(postId, {
|
||
$set: {
|
||
post_images: [relativeThumb],
|
||
},
|
||
});
|
||
console.log(`[postVideoProcessing] thumbnail saved post=${postId} path=${relativeThumb}`);
|
||
} catch (error) {
|
||
console.error("[postVideoProcessing] db update failed:", error.message);
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
processPostVideoInBackground,
|
||
optimizeVideoFaststart,
|
||
generateVideoThumbnail,
|
||
};
|