chore: relocate backend into apps/api ahead of monorepo merge

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:20:40 +08:00
co-authored by Claude Sonnet 5
parent af44598df6
commit eaa6d2276a
388 changed files with 0 additions and 13998 deletions
+170
View File
@@ -0,0 +1,170 @@
// 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<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, 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 };