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); };