39948-vm/backend/src/services/videoProcessing.js

214 lines
5.9 KiB
JavaScript

/**
* 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<Buffer>} 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<boolean>}
*/
async function isFFmpegAvailable() {
return new Promise((resolve) => {
ffmpeg.getAvailableFormats((err) => {
resolve(!err);
});
});
}
module.exports = {
reverseVideo,
isFFmpegAvailable,
probeMediaMetadata,
};