// services/ffprobe.service.js // // Extracts media metadata (dimensions, duration, codecs, bitrate, frame rate). // Also used for audio-only files — video-specific fields (width/height/ // frame_rate/video_codec) simply resolve to null when there's no video stream. // Thumbnail is provided by the client as a separate uploaded file — not generated here. // // Dependencies: // npm install fluent-ffmpeg ffprobe-static const ffmpeg = require("fluent-ffmpeg"); const ffprobeStatic = require("ffprobe-static"); const os = require("os"); const path = require("path"); const fs = require("fs"); // Use system ffprobe if available, otherwise fall back to the static binary. try { const { execSync } = require("child_process"); execSync("which ffprobe", { stdio: "ignore" }); // system binary found — fluent-ffmpeg picks it up automatically } catch { ffmpeg.setFfprobePath(ffprobeStatic.path); } // ─── Internal helpers ───────────────────────────────────────────────────────── function writeTempFile(buffer, extension) { const tmpPath = path.join( os.tmpdir(), `asset_${Date.now()}_${Math.random().toString(36).slice(2)}.${extension}`, ); fs.writeFileSync(tmpPath, buffer); return tmpPath; } function cleanupTempFile(filePath) { try { fs.unlinkSync(filePath); } catch { /* ignore */ } } /** * Parse frame rate from ffprobe's fraction string (e.g. "30/1", "24000/1001"). */ function parseFrameRate(rateStr = "") { if (!rateStr || rateStr === "0/0") return null; const [num, den] = rateStr.split("/").map(Number); if (!den || den === 0) return num || null; return parseFloat((num / den).toFixed(3)); } /** * Resolve human-readable resolution label. Mirrors the controller helper. */ function resolveResolution(width, height) { if (!width || !height) return null; const h = Math.min(width, height); if (h >= 2160) return "4K"; if (h >= 1440) return "1440p"; if (h >= 1080) return "1080p"; if (h >= 720) return "720p"; if (h >= 480) return "480p"; if (h >= 360) return "360p"; if (h >= 240) return "240p"; return `${width}x${height}`; } function probeFile(filePath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) return reject(err); resolve(metadata); }); }); } // ffprobe/libavformat accepts http(s):// URLs directly — used for presigned // -upload assets, where no buffer or local temp file exists at all (the // browser PUT the bytes straight to storage, this backend never touched // them). No default network timeout applies to a remote input the way it // would to a local file, so this races the probe against an explicit one to // avoid hanging the finalize request on a slow/stuck remote read. function probeUrl(url, timeoutMs = 30_000) { return Promise.race([ new Promise((resolve, reject) => { ffmpeg.ffprobe(url, (err, metadata) => { if (err) return reject(err); resolve(metadata); }); }), new Promise((_, reject) => setTimeout(() => reject(new Error(`ffprobe timed out after ${timeoutMs}ms`)), timeoutMs) ), ]); } // ─── Public API ─────────────────────────────────────────────────────────────── /** * Extract video metadata from either a Buffer or a remote URL. * Thumbnail is NOT generated here — the client uploads it as a separate file. * * @param {object} opts * @param {Buffer} [opts.buffer] raw video bytes (multer memoryStorage) — * written to a temp file and probed there. * @param {string} [opts.extension] file extension without dot, e.g. "mp4" — * only used with `buffer`. * @param {string} [opts.url] a presigned GET URL (or any ffprobe/ * libavformat-reachable address) to probe * directly, with no local buffer or temp * file at all — used for presigned-upload * assets. Takes precedence over `buffer` * when both are given. * * @returns {Promise} * * @typedef {object} VideoMeta * @property {number|null} width * @property {number|null} height * @property {string|null} resolution "1080p", "720p", "4K", … * @property {number|null} duration seconds * @property {number|null} frame_rate fps * @property {number|null} bitrate bps * @property {string|null} video_codec "H.264", "H.265", … * @property {string|null} audio_codec "AAC", "MP3", … */ async function extractVideoMeta({ buffer, extension, url }) { const tmpPath = url ? null : writeTempFile(buffer, extension || "mp4"); try { const raw = await (tmpPath ? probeFile(tmpPath) : probeUrl(url)); const videoStream = raw.streams?.find((s) => s.codec_type === "video") || {}; const audioStream = raw.streams?.find((s) => s.codec_type === "audio") || {}; const format = raw.format || {}; const width = videoStream.width || null; const height = videoStream.height || null; const duration = parseFloat(format.duration || videoStream.duration || 0) || null; const bitrate = parseInt(format.bit_rate || videoStream.bit_rate || 0, 10) || null; const frame_rate = parseFrameRate(videoStream.r_frame_rate || videoStream.avg_frame_rate); const resolution = resolveResolution(width, height); const VIDEO_CODEC_MAP = { h264: "H.264", avc1: "H.264", h265: "H.265", hevc: "H.265", vp8: "VP8", vp9: "VP9", av1: "AV1", }; const AUDIO_CODEC_MAP = { aac: "AAC", mp3: "MP3", mp3float: "MP3", opus: "Opus", vorbis: "Vorbis", flac: "FLAC", pcm_s16le: "PCM", }; const video_codec = VIDEO_CODEC_MAP[(videoStream.codec_name || "").toLowerCase()] || videoStream.codec_name || null; const audio_codec = AUDIO_CODEC_MAP[(audioStream.codec_name || "").toLowerCase()] || audioStream.codec_name || null; return { width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec }; } finally { if (tmpPath) cleanupTempFile(tmpPath); } } module.exports = { extractVideoMeta };