Exposes video stream fps/codec/resolution and container duration/size so the proxy worker can populate asset metadata after transcoding.
132 lines
3.2 KiB
JavaScript
132 lines
3.2 KiB
JavaScript
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;
|
|
}
|
|
|
|
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,
|
|
};
|
|
};
|
|
|
|
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',
|
|
} = options;
|
|
|
|
const args = [
|
|
'-i', inputPath,
|
|
'-c:v', videoCodec,
|
|
'-preset', videoPreset,
|
|
'-b:v', videoBitrate,
|
|
'-c:a', audioCodec,
|
|
'-b:a', audioBitrate,
|
|
'-movflags', '+faststart',
|
|
'-y',
|
|
outputPath,
|
|
];
|
|
|
|
await runFFmpeg(args);
|
|
};
|
|
|
|
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);
|
|
};
|