// helpers/hlsManager.js const fs = require('fs'); const path = require('path'); const { spawn, execFileSync } = require('child_process'); const fetch = require('node-fetch'); // node-fetch@2 const config = require('../api_v1/config'); // ----------------------------------------------------------------------------- // Configuration // ----------------------------------------------------------------------------- const FFMPEG_BIN = '/usr/local/bin/ffmpeg'; const HLS_BASE = path.resolve( process.env.HLS_BASE || path.join(process.cwd(), 'hls_output') ); const jobs = new Map(); // ----------------------------------------------------------------------------- // FFmpeg / RKMPP detection // ----------------------------------------------------------------------------- function getFfmpegEncoders() { try { return execFileSync( FFMPEG_BIN, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } ); } catch (err) { console.error( `[hlsManager] Cannot execute ${FFMPEG_BIN}: ${err.message}` ); return ''; } } function hasRkmpp() { const out = getFfmpegEncoders(); const hasH264 = /\bh264_rkmpp\b/.test(out); const hasHevc = /\bhevc_rkmpp\b/.test(out); if (!hasH264 || !hasHevc) { console.warn( `[hlsManager] RKMPP not fully available: ` + `h264_rkmpp=${hasH264}, hevc_rkmpp=${hasHevc}` ); return false; } console.log( `[hlsManager] RKMPP available: ` + `h264_rkmpp=${hasH264}, hevc_rkmpp=${hasHevc}` ); return true; } // ----------------------------------------------------------------------------- // FFmpeg arguments // ----------------------------------------------------------------------------- function buildFfmpegArgs(inputPath, jobDir, useRkmpp) { const master = path.join(jobDir, 'master.m3u8'); const segPattern = path.join(jobDir, 'segment_%05d.ts'); if (useRkmpp) { return [ '-hide_banner', '-loglevel', 'verbose', '-y', // Input probing '-probesize', '5000000', '-analyzeduration', '10000000', // IMPORTANT: // Do not use "-hwaccel rkmpp" here. // The tested HEVC decoder is explicitly hevc_rkmpp. '-c:v', 'hevc_rkmpp', '-i', inputPath, // Streams '-map', '0:v:0', '-map', '0:a:0?', // --------------------------------------------------------------------- // Video: Rockchip MPP H.264 encoder // --------------------------------------------------------------------- '-c:v', 'h264_rkmpp', '-rc_mode', 'CBR', '-b:v', '5000k', '-maxrate', '6000k', '-bufsize', '12000k', // Fixed output framerate '-r', '30', // 90 frames @ 30 fps = ~3 seconds // This matches hls_time=3. '-g', '90', '-keyint_min', '90', // --------------------------------------------------------------------- // Audio // --------------------------------------------------------------------- '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', // --------------------------------------------------------------------- // HLS // --------------------------------------------------------------------- '-f', 'hls', '-hls_time', '3', '-hls_list_size', '0', '-hls_playlist_type', 'vod', '-hls_segment_type', 'mpegts', '-hls_segment_filename', segPattern, master ]; } // --------------------------------------------------------------------------- // CPU fallback // --------------------------------------------------------------------------- return [ '-hide_banner', '-y', '-probesize', '5000000', '-analyzeduration', '10000000', '-noautorotate', '-i', inputPath, '-map', '0:v:0', '-map', '0:a:0?', // CPU H.264 '-c:v', 'libx264', '-profile:v', 'high', '-level', '4.1', '-preset', 'veryfast', '-tune', 'zerolatency', '-b:v', '5000k', '-maxrate', '6000k', '-bufsize', '12000k', '-r', '30', '-g', '90', '-keyint_min', '90', '-sc_threshold', '0', '-pix_fmt', 'yuv420p', // Audio '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', // HLS '-f', 'hls', '-hls_time', '3', '-hls_list_size', '0', '-hls_playlist_type', 'vod', '-hls_segment_type', 'mpegts', '-hls_segment_filename', segPattern, master ]; } // ----------------------------------------------------------------------------- // Callback // ----------------------------------------------------------------------------- async function sendCallback(job, payload) { if (!job.callbackUrl) { return; } try { const resp = await fetch(job.callbackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), timeout: 10000 }); console.log( `[job ${job.jobId}] callback ${resp.status}` ); } catch (err) { console.error( `[job ${job.jobId}] callback failed: ${err.message}` ); } } // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- function countSegments(jobDir) { if (!fs.existsSync(jobDir)) { return 0; } try { return fs.readdirSync(jobDir) .filter(name => /^segment_\d+\.ts$/.test(name)) .length; } catch (err) { console.error( `[hlsManager] Cannot read ${jobDir}: ${err.message}` ); return 0; } } function buildPublicUrl(job, masterPath) { const base = job.publicBaseUrl || config.BASE_URL || null; const rel = path .relative(HLS_BASE, masterPath) .split(path.sep) .join('/'); if (base) { return `${base.replace(/\/$/, '')}/hls/${rel}`; } return `/${rel}`; } // ----------------------------------------------------------------------------- // Start job // ----------------------------------------------------------------------------- function startJob({ inputPath, outDir, segmentsNeeded = 3, callbackUrl, publicBaseUrl = null }) { if (!inputPath) { throw new Error('inputPath is required'); } if (!outDir) { throw new Error('outDir is required'); } const jobId = Date.now().toString(); const jobDir = path.join( outDir, jobId ); fs.mkdirSync( jobDir, { recursive: true } ); const useRkmpp = hasRkmpp(); const ffArgs = buildFfmpegArgs( inputPath, jobDir, useRkmpp ); console.log( `[job ${jobId}] Starting FFmpeg` ); console.log( `[job ${jobId}] binary: ${FFMPEG_BIN}` ); console.log( `[job ${jobId}] RKMPP: ${useRkmpp}` ); console.log( `[job ${jobId}] input: ${inputPath}` ); console.log( `[job ${jobId}] output: ${jobDir}` ); console.log( `[job ${jobId}] args: ${ffArgs.join(' ')}` ); const proc = spawn( FFMPEG_BIN, ffArgs, { stdio: [ 'ignore', 'pipe', 'pipe' ] } ); const job = { jobId, inputPath, outDir: jobDir, callbackUrl, segmentsNeeded: Math.max(1, Number(segmentsNeeded)), proc, notified: false, startTime: Date.now(), useRkmpp, publicBaseUrl, masterPath: path.join( jobDir, 'master.m3u8' ), exitCode: null, error: null, poller: null }; jobs.set( jobId, job ); // --------------------------------------------------------------------------- // FFmpeg stdout // --------------------------------------------------------------------------- if (proc.stdout) { proc.stdout.setEncoding('utf8'); proc.stdout.on( 'data', data => { const text = data.toString().trim(); if (text) { console.log( `[ffmpeg ${jobId}] ${text}` ); } } ); } // --------------------------------------------------------------------------- // FFmpeg stderr // --------------------------------------------------------------------------- if (proc.stderr) { proc.stderr.setEncoding('utf8'); proc.stderr.on( 'data', data => { const text = data.toString().trim(); if (text) { console.log( `[ffmpeg ${jobId}] ${text}` ); } } ); } // --------------------------------------------------------------------------- // Spawn error // --------------------------------------------------------------------------- proc.on( 'error', err => { job.error = err; console.error( `[job ${jobId}] FFmpeg process error: ${err.message}` ); } ); // --------------------------------------------------------------------------- // Poll HLS output // --------------------------------------------------------------------------- const pollInterval = 500; const timeoutMs = 5 * 60 * 1000; const poller = setInterval( async () => { try { if ( !job.notified && fs.existsSync(job.masterPath) ) { const tsCount = countSegments(job.outDir); if ( tsCount >= job.segmentsNeeded ) { job.notified = true; const publicUrl = buildPublicUrl( job, job.masterPath ); console.log( `[job ${jobId}] HLS ready: ` + `${tsCount} segments` ); await sendCallback( job, { jobId: job.jobId, master_m3u8: publicUrl, ready_segments: tsCount, status: 'ready' } ); } } // --------------------------------------------------------------------- // Timeout // --------------------------------------------------------------------- if ( !job.notified && Date.now() - job.startTime > timeoutMs ) { job.notified = true; console.error( `[job ${jobId}] HLS timeout` ); await sendCallback( job, { jobId: job.jobId, out_dir: job.outDir, status: 'timeout' } ); // Do not kill FFmpeg here automatically. // The process may still be finishing a large input. } } catch (err) { console.error( `[job ${jobId}] poller error: ${err.message}` ); } }, pollInterval ); job.poller = poller; // --------------------------------------------------------------------------- // FFmpeg exit // --------------------------------------------------------------------------- proc.on( 'exit', async code => { clearInterval( poller ); job.exitCode = code; try { const masterExists = fs.existsSync( job.masterPath ); const tsCount = countSegments( job.outDir ); console.log( `[job ${jobId}] FFmpeg exited ` + `with code ${code}, ` + `segments=${tsCount}` ); if (!job.notified) { const publicUrl = masterExists ? buildPublicUrl( job, job.masterPath ) : null; let status; if (code === 0 && masterExists) { status = 'finished'; } else { status = 'error'; } job.notified = true; await sendCallback( job, { jobId: job.jobId, master_m3u8: publicUrl, ready_segments: tsCount, status, returncode: code } ); } } catch (err) { console.error( `[job ${jobId}] on-exit callback error: ${err.message}` ); } finally { jobs.delete( jobId ); } } ); return jobId; } // ----------------------------------------------------------------------------- // Get job // ----------------------------------------------------------------------------- function getJob(jobId) { return jobs.get( jobId ) || null; } // ----------------------------------------------------------------------------- // Exports // ----------------------------------------------------------------------------- module.exports = { startJob, getJob };