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); }; const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']); const VAAPI_CODECS = new Set(['h264_vaapi', 'hevc_vaapi']); const HW_CODECS = new Set([...NVENC_CODECS, ...VAAPI_CODECS]); export const transcodeVideo = async (inputPath, outputPath, options = {}) => { const { videoCodec = 'libx264', videoPreset = 'fast', videoBitrate = '750k', // average/target for VBR videoMinRate = null, // VBR minimum e.g. '500k' videoMaxRate = null, // VBR maximum e.g. '1000k' videoBufSize = null, // VBR buffer e.g. '2000k' (2× maxrate recommended) rateControl = null, // 'cbr' | 'vbr' | 'cqp' — optional override for HW codecs audioCodec = 'aac', audioBitrate = '128k', hasAudio = true, } = options; // libx264 / yuv420p require even dimensions. const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p"; const args = [ '-analyzeduration', '100M', '-probesize', '100M', '-i', inputPath, '-vf', vf, '-c:v', videoCodec, '-preset', videoPreset, '-b:v', videoBitrate, ]; // VBR min/max/bufsize for libx264 ABR mode. // When minrate+maxrate are set, libx264 operates in ABR with hard limits // rather than strict CBR — quality varies per-scene within the envelope. if (videoMinRate) args.push('-minrate', videoMinRate); if (videoMaxRate) args.push('-maxrate', videoMaxRate); if (videoBufSize) args.push('-bufsize', videoBufSize); // NVENC/VAAPI hardware rate control flags if (rateControl) { if (NVENC_CODECS.has(videoCodec)) { args.push('-rc', rateControl); } else if (VAAPI_CODECS.has(videoCodec)) { args.push('-rc_mode', rateControl.toUpperCase()); } } if (hasAudio) { args.push('-c:a', audioCodec, '-b:a', audioBitrate); } else { args.push('-an'); } args.push('-movflags', '+faststart', '-y', outputPath); await runFFmpeg(args); }; export const isHwCodec = (codec) => HW_CODECS.has(codec); // 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 info = await getMediaInfo(inputPath); const fps = info.fps || 30; const inSeconds = inPoint / fps; const frameCount = outPoint - inPoint; const args = [ '-ss', inSeconds.toString(), '-i', inputPath, '-frames:v', frameCount.toString(), '-start_number', '0', '-y', outputPath, ]; await runFFmpeg(args); }; // Segment an existing MP4/MOV into HLS (fMP4) — init.mp4 + segment_*.m4s + // playlist.m3u8. Keeps the original codec (no re-encode) so this is cheap to // run after the proxy transcode. fMP4 segments stay <5 MB at our proxy // bitrate, which sidesteps RustFS's broken byte-range path on large objects. export const segmentToHls = async (inputPath, outputDir, options = {}) => { const { segmentDurationSec = 4, playlistName = 'playlist.m3u8', initName = 'init.mp4', segmentPattern = 'segment_%05d.m4s', } = options; const args = [ '-i', inputPath, '-c', 'copy', '-f', 'hls', '-hls_time', String(segmentDurationSec), '-hls_playlist_type', 'vod', '-hls_segment_type', 'fmp4', '-hls_flags', 'independent_segments', '-hls_fmp4_init_filename', initName, '-hls_segment_filename', `${outputDir}/${segmentPattern}`, '-y', `${outputDir}/${playlistName}`, ]; await runFFmpeg(args); }; export const concatSegments = async (segmentListFile, outputPath) => { const args = [ '-f', 'concat', '-safe', '0', '-i', segmentListFile, '-c', 'copy', '-y', outputPath, ]; await runFFmpeg(args); };