/** * Video Processing Service * * Provides video manipulation operations using FFmpeg. * Used for generating reversed videos for back navigation transitions. */ const ffmpeg = require('fluent-ffmpeg'); const ffmpegPath = require('ffmpeg-static'); const ffprobePath = require('ffprobe-static').path; const fs = require('fs').promises; const path = require('path'); const os = require('os'); const { logger } = require('../utils/logger'); // Configure fluent-ffmpeg to use bundled binaries ffmpeg.setFfmpegPath(ffmpegPath); ffmpeg.setFfprobePath(ffprobePath); let ffmpegQueueTail = Promise.resolve(); let queuedFfmpegJobs = 0; let ffmpegJobSequence = 0; function parseFrameRate(value) { if (!value) return null; if (typeof value === 'number') { return Number.isFinite(value) && value > 0 ? value : null; } const normalized = String(value).trim(); if (!normalized || normalized === '0/0') return null; if (normalized.includes('/')) { const [numeratorRaw, denominatorRaw] = normalized.split('/'); const numerator = Number(numeratorRaw); const denominator = Number(denominatorRaw); if (!Number.isFinite(numerator) || !Number.isFinite(denominator)) { return null; } if (denominator === 0) return null; const fps = numerator / denominator; return Number.isFinite(fps) && fps > 0 ? fps : null; } const parsed = Number(normalized); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } async function enqueueFfmpegJob(jobName, runJob) { const jobId = ++ffmpegJobSequence; const queuedAhead = queuedFfmpegJobs; queuedFfmpegJobs++; logger.info( { jobId, jobName, queuedAhead }, 'FFmpeg job queued for single-worker execution', ); const previousTail = ffmpegQueueTail.catch(() => { // Keep the queue moving even if the previous job failed. }); const jobPromise = previousTail.then(async () => { queuedFfmpegJobs = Math.max(0, queuedFfmpegJobs - 1); logger.info( { jobId, jobName, queuedRemaining: queuedFfmpegJobs }, 'FFmpeg job started', ); try { return await runJob(); } finally { logger.info({ jobId, jobName }, 'FFmpeg job finished'); } }); ffmpegQueueTail = jobPromise; return jobPromise; } /** * Reverse a video using FFmpeg * @param {Buffer} inputBuffer - Input video buffer * @param {string} filename - Original filename (for extension) * @returns {Promise} Reversed video buffer */ async function reverseVideo(inputBuffer, filename) { return enqueueFfmpegJob('reverseVideo', () => reverseVideoWithoutQueue(inputBuffer, filename), ); } async function reverseVideoWithoutQueue(inputBuffer, filename) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'video-reverse-')); const ext = path.extname(filename) || '.mp4'; const inputPath = path.join(tempDir, `input${ext}`); const outputPath = path.join(tempDir, `reversed${ext}`); try { // Write input buffer to temp file await fs.writeFile(inputPath, inputBuffer); logger.info({ inputPath, outputPath }, 'Starting video reversal'); // Reverse video with FFmpeg await new Promise((resolve, reject) => { ffmpeg(inputPath) .outputOptions([ '-vf', 'reverse', '-af', 'areverse', '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-c:a', 'aac', '-threads', '1', '-movflags', '+faststart', ]) .output(outputPath) .on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command')) .on('progress', (progress) => { if (progress.percent) { logger.debug({ percent: progress.percent }, 'FFmpeg progress'); } }) .on('end', () => { logger.info({ outputPath }, 'Video reversal complete'); resolve(); }) .on('error', (err, stdout, stderr) => { logger.error({ err, stdout, stderr }, 'FFmpeg error'); reject(err); }) .run(); }); // Read output as buffer return await fs.readFile(outputPath); } finally { // Cleanup temp files try { await fs.rm(tempDir, { recursive: true, force: true }); } catch (cleanupErr) { logger.warn({ err: cleanupErr, tempDir }, 'Failed to cleanup temp dir'); } } } async function probeMediaMetadata(filePath) { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { if (err) { reject(err); return; } const videoStream = metadata?.streams?.find( (stream) => stream.codec_type === 'video', ); const audioStream = metadata?.streams?.find( (stream) => stream.codec_type === 'audio', ); const primaryStream = videoStream || audioStream || null; const formatDuration = Number(metadata?.format?.duration); const streamDuration = Number(primaryStream?.duration); const durationSec = Number.isFinite(formatDuration) && formatDuration > 0 ? formatDuration : Number.isFinite(streamDuration) && streamDuration > 0 ? streamDuration : null; const widthPx = Number(videoStream?.width); const heightPx = Number(videoStream?.height); const frameRate = parseFrameRate(videoStream?.avg_frame_rate) || parseFrameRate(videoStream?.r_frame_rate); resolve({ durationSec, widthPx: Number.isFinite(widthPx) && widthPx > 0 ? widthPx : null, heightPx: Number.isFinite(heightPx) && heightPx > 0 ? heightPx : null, frameRate, }); }); }); } /** * Check if FFmpeg is available * @returns {Promise} */ async function isFFmpegAvailable() { return new Promise((resolve) => { ffmpeg.getAvailableFormats((err) => { resolve(!err); }); }); } module.exports = { reverseVideo, isFFmpegAvailable, probeMediaMetadata, };