dragonflight/services/worker/src/ffmpeg/executor.js

162 lines
4.3 KiB
JavaScript
Raw Normal View History

import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export const runFFmpeg = async (args, options = {}) => {
const { stdio = 'pipe', ...otherOptions } = options;
try {
const { stdout, stderr } = await execFileAsync('ffmpeg', args, {
stdio,
...otherOptions,
});
return { stdout, stderr };
} catch (error) {
throw new Error(`FFmpeg error: ${error.message}\nStderr: ${error.stderr}`);
}
};
const runFFprobe = async (args) => {
try {
const { stdout } = await execFileAsync('ffprobe', args);
return { stdout };
} catch (error) {
throw new Error(`FFprobe error: ${error.message}\nStderr: ${error.stderr}`);
}
};
export const getMediaDuration = async (inputPath) => {
const args = [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inputPath,
];
const { stdout } = await runFFprobe(args);
return parseFloat(stdout.trim());
};
/**
* Return structured media metadata for an input file.
* Result shape:
* { fps, codec, resolution, durationMs, fileSizeBytes }
* Any field may be null if ffprobe cannot determine it.
*/
export const getMediaInfo = async (inputPath) => {
const args = [
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_format',
inputPath,
];
const { stdout } = await runFFprobe(args);
const info = JSON.parse(stdout);
const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
const fmt = info.format || {};
let fps = null;
if (videoStream?.r_frame_rate) {
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
if (den > 0) fps = Math.round((num / den) * 1000) / 1000;
}
const hasAudio = (info.streams || []).some(s => s.codec_type === 'audio');
return {
fps,
codec: videoStream?.codec_name || null,
resolution: videoStream ? `${videoStream.width}x${videoStream.height}` : null,
durationMs: fmt.duration ? Math.round(parseFloat(fmt.duration) * 1000) : null,
fileSizeBytes: fmt.size ? parseInt(fmt.size, 10) : null,
hasAudio,
};
};
export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
const args = [
'-ss', timeCode,
'-i', inputPath,
'-vframes', '1',
'-q:v', '2',
'-y',
outputPath,
];
await runFFmpeg(args);
};
export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
const {
videoCodec = 'libx264',
videoPreset = 'fast',
videoBitrate = '10M',
audioCodec = 'aac',
audioBitrate = '192k',
hasAudio = true,
} = options;
// libx264 / yuv420p require even width AND height. Captured frames from SDI
// or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125). Force-even
// before pixel-format conversion so the encoder never sees odd dimensions.
const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";
// analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
// write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
// default 5MB probe — bump to 100MB so we always read enough of the file.
const args = [
'-analyzeduration', '100M',
'-probesize', '100M',
'-i', inputPath,
'-vf', vf,
'-c:v', videoCodec,
'-preset', videoPreset,
'-b:v', videoBitrate,
];
if (hasAudio) {
args.push('-c:a', audioCodec, '-b:a', audioBitrate);
} else {
args.push('-an');
}
args.push('-movflags', '+faststart', '-y', outputPath);
await runFFmpeg(args);
};
// Single-frame poster — used as a fallback "proxy" for still-image assets
// so the library can show them without a transcoded video.
export const transcodeImage = async (inputPath, outputPath) => {
await runFFmpeg([
'-i', inputPath,
'-vf', "scale='min(1920,iw)':-2",
'-q:v', '3',
'-y', outputPath,
]);
};
export const trimSegment = async (inputPath, outputPath, inPoint, outPoint) => {
const args = [
'-i', inputPath,
'-ss', inPoint,
'-to', outPoint,
'-c', 'copy',
'-y',
outputPath,
];
await runFFmpeg(args);
};
export const concatSegments = async (segmentListFile, outputPath) => {
const args = [
'-f', 'concat',
'-safe', '0',
'-i', segmentListFile,
'-c', 'copy',
'-y',
outputPath,
];
await runFFmpeg(args);
};