@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
207 lines • 7.83 kB
JavaScript
/**
* Shared FFmpeg Adapter for Video Operations
*
* Centralizes FFmpeg binary resolution, process execution, and temporary file
* management for all video adapter modules (frameExtractor, videoMerger).
*
* Follows the adapter pattern used in `src/lib/adapters/tts/` and
* `src/lib/adapters/providerImageAdapter.ts`.
*
* @module adapters/video/ffmpegAdapter
*/
import { randomUUID } from "node:crypto";
import { readdirSync, rmdirSync, unlinkSync } from "node:fs";
import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { logger } from "../../utils/logger.js";
// ============================================================================
// CONSTANTS
// ============================================================================
/** Timeout for frame-extraction FFmpeg operations (30 seconds) */
export const FFMPEG_FRAME_TIMEOUT_MS = 30_000;
/** Timeout for merge/concat FFmpeg operations (2 minutes) */
export const FFMPEG_MERGE_TIMEOUT_MS = 120_000;
/** Max stdout/stderr buffer for frame extraction (10 MB) */
export const FFMPEG_FRAME_MAX_BUFFER = 10 * 1024 * 1024;
/** Max stdout/stderr buffer for merge operations (50 MB) */
export const FFMPEG_MERGE_MAX_BUFFER = 50 * 1024 * 1024;
/** FFmpeg JPEG quality scale (2 = high quality, range 2-31) */
export const JPEG_QUALITY = "2";
/** Seconds before end-of-video to seek when extracting last frame */
export const LAST_FRAME_SEEK_OFFSET = "0.5";
/** Minimum valid MP4 buffer size in bytes (ftyp header = 8 bytes minimum) */
export const MIN_VIDEO_BUFFER_SIZE = 12;
/** MP4 ftyp box magic bytes at offset 4: "ftyp" */
const FTYP_MAGIC = Buffer.from([0x66, 0x74, 0x79, 0x70]);
// ============================================================================
// TEMP DIRECTORY MANAGEMENT
// ============================================================================
/** Track active temp directories for process cleanup */
const activeTempDirs = new Set();
let cleanupRegistered = false;
/**
* Register process-level cleanup handlers once.
* Removes all tracked temp directories on abnormal exit.
*/
function ensureCleanupRegistered() {
if (cleanupRegistered) {
return;
}
cleanupRegistered = true;
const cleanup = () => {
for (const dir of activeTempDirs) {
try {
// Sync removal for exit handlers — best-effort only
const entries = readdirSync(dir);
for (const entry of entries) {
try {
unlinkSync(join(dir, entry));
}
catch {
/* best-effort */
}
}
rmdirSync(dir);
}
catch {
/* best-effort */
}
}
activeTempDirs.clear();
};
process.on("exit", cleanup);
}
/**
* Create a tracked temporary directory for FFmpeg operations.
*
* @param prefix - Directory name prefix (e.g. "frame", "merge")
* @returns Absolute path to the created directory
*/
export async function createTrackedTempDir(prefix) {
ensureCleanupRegistered();
const dir = join(tmpdir(), `neurolink-${prefix}-${randomUUID()}`);
await mkdir(dir, { recursive: true });
activeTempDirs.add(dir);
return dir;
}
/**
* Clean up temporary files and their parent directory.
* Logs failures at debug level instead of swallowing silently.
*
* @param tempDir - The temporary directory to remove
* @param files - File paths within tempDir to delete
*/
export async function cleanupTempFiles(tempDir, ...files) {
for (const filePath of files) {
try {
await unlink(filePath);
}
catch (err) {
logger.debug("Failed to clean up temp file", {
path: filePath,
error: err instanceof Error ? err.message : String(err),
});
}
}
try {
await rm(tempDir, { recursive: true, force: true });
}
catch (err) {
logger.debug("Failed to remove temp directory", {
path: tempDir,
error: err instanceof Error ? err.message : String(err),
});
}
activeTempDirs.delete(tempDir);
}
// ============================================================================
// FFMPEG BINARY RESOLUTION
// ============================================================================
/** Cached FFmpeg binary path to avoid repeated resolution */
let cachedFfmpegPath = null;
/**
* Resolve the FFmpeg binary path.
*
* Resolution order:
* 1. `FFMPEG_PATH` environment variable
* 2. `ffmpeg-static` npm package (optional peer dependency)
* 3. System `ffmpeg` on PATH
*
* @returns Absolute or relative path to the FFmpeg binary
*/
export async function getFfmpegPath() {
if (cachedFfmpegPath) {
return cachedFfmpegPath;
}
if (process.env.FFMPEG_PATH) {
cachedFfmpegPath = process.env.FFMPEG_PATH;
return cachedFfmpegPath;
}
try {
const ffmpegStatic = await import("ffmpeg-static");
const staticPath = ffmpegStatic.default ?? ffmpegStatic;
if (typeof staticPath === "string" && staticPath.length > 0) {
cachedFfmpegPath = staticPath;
return cachedFfmpegPath;
}
}
catch {
// ffmpeg-static not installed — fall through to system binary
logger.debug("ffmpeg-static not available, using system ffmpeg binary");
}
cachedFfmpegPath = "ffmpeg";
logger.warn("Using system ffmpeg binary. If video operations fail with ENOENT, install ffmpeg-static or set FFMPEG_PATH.");
return cachedFfmpegPath;
}
// ============================================================================
// FFMPEG PROCESS EXECUTION
// ============================================================================
/**
* Run an FFmpeg command via `child_process.execFile`.
*
* @param args - FFmpeg CLI arguments (without the binary path)
* @param options - Timeout and buffer size overrides
* @returns stdout and stderr from the process
* @throws Error if the process exits with a non-zero code or times out
*/
export async function runFfmpeg(args, options = {}) {
const { execFile } = await import("node:child_process");
const ffmpegPath = await getFfmpegPath();
const timeoutMs = options.timeoutMs ?? FFMPEG_FRAME_TIMEOUT_MS;
const maxBuffer = options.maxBuffer ?? FFMPEG_FRAME_MAX_BUFFER;
return new Promise((resolve, reject) => {
const proc = execFile(ffmpegPath, args, { timeout: timeoutMs, maxBuffer }, (error, stdout, stderr) => {
if (error) {
reject(error);
}
else {
resolve({ stdout: stdout || "", stderr: stderr || "" });
}
});
proc.on("error", reject);
});
}
// ============================================================================
// BUFFER VALIDATION
// ============================================================================
/**
* Validate that a buffer looks like a valid MP4 video.
*
* Checks minimum size and the presence of an `ftyp` box header.
*
* @param buffer - Buffer to validate
* @returns `true` if the buffer passes basic MP4 validation
*/
export function isValidMp4Buffer(buffer) {
if (buffer.length < MIN_VIDEO_BUFFER_SIZE) {
return false;
}
// Check for ftyp box: bytes 4-7 should be "ftyp"
return buffer.subarray(4, 8).equals(FTYP_MAGIC);
}
// ============================================================================
// FILE I/O HELPERS
// ============================================================================
export { writeFile, readFile, join };
//# sourceMappingURL=ffmpegAdapter.js.map