Files
modstagram-back/utils/postVideoProcessing.js
payacom e1abb40cc6 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>
2026-09-15 17:55:09 +03:30

146 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};