mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
142 lines
4.9 KiB
JavaScript
142 lines
4.9 KiB
JavaScript
// 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);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract video metadata from a Buffer.
|
|
* 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)
|
|
* @param {string} opts.extension file extension without dot, e.g. "mp4"
|
|
*
|
|
* @returns {Promise<VideoMeta>}
|
|
*
|
|
* @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 }) {
|
|
const tmpPath = writeTempFile(buffer, extension || "mp4");
|
|
|
|
try {
|
|
const raw = await probeFile(tmpPath);
|
|
|
|
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 {
|
|
cleanupTempFile(tmpPath);
|
|
}
|
|
}
|
|
|
|
module.exports = { extractVideoMeta }; |