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, };