214 lines
5.9 KiB
JavaScript
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,
|
|
};
|