// helpers/hlsManager2.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'; /* * Temporary private MPP library. * * Once MPP 0986d01 is installed as the normal system library, * remove this constant and the "env: FFMPEG_ENV" options below. */ const FFMPEG_ENV = { ...process.env, LD_LIBRARY_PATH: [ '/usr/local/lib/mpp-0986d01', process.env.LD_LIBRARY_PATH ] .filter(Boolean) .join(':') }; 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', env: FFMPEG_ENV, 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'); // Filtro video: 720p, proporzioni originali, dimensioni compatibili // con il codec e formato yuv420p. const videoFilter = 'scale=1280:1080:force_original_aspect_ratio=decrease:force_divisible_by=16,' + 'pad=1280:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p'; if (useRkmpp) { return [ '-hide_banner', '-loglevel', 'warning', '-y', '-probesize', '5000000', '-analyzeduration', '10000000', // Decodifica hardware RKMPP. '-hwaccel', 'rkmpp', '-i', inputPath, '-map', '0:v:0', '-map', '0:a:0?', // Ridimensionamento a 720p e codifica H.264 hardware. '-vf', videoFilter, '-c:v', 'h264_rkmpp', '-rc_mode', 'CBR', '-b:v', '2500k', '-maxrate', '3000k', '-bufsize', '6000k', '-r', '30', '-g', '90', '-keyint_min', '90', // Audio AAC stereo. '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', // HLS event: la playlist può crescere durante il transcoding. '-f', 'hls', '-hls_time', '3', '-hls_list_size', '0', '-hls_playlist_type', 'event', '-hls_segment_type', 'mpegts', '-hls_flags', 'independent_segments', '-hls_segment_filename', segPattern, master ]; } // Fallback CPU: mantiene il comportamento precedente. return [ '-hide_banner', '-y', '-probesize', '5000000', '-analyzeduration', '10000000', '-noautorotate', '-i', inputPath, '-map', '0:v:0', '-map', '0:a:0?', '-vf', videoFilter, '-c:v', 'libx264', '-profile:v', 'high', '-level', '4.1', '-preset', 'veryfast', '-tune', 'zerolatency', '-b:v', '2500k', '-maxrate', '3000k', '-bufsize', '6000k', '-r', '30', '-g', '90', '-keyint_min', '90', '-sc_threshold', '0', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', '-f', 'hls', '-hls_time', '3', '-hls_list_size', '0', '-hls_playlist_type', 'event', '-hls_segment_type', 'mpegts', '-hls_flags', 'independent_segments', '-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 publicRoot = path.resolve( process.cwd(), 'public' ); let publicPath; if ( masterPath === publicRoot || masterPath.startsWith( `${publicRoot}${path.sep}` ) ) { const rel = path .relative(publicRoot, masterPath) .split(path.sep) .join('/'); publicPath = `/${rel}`; } else { /* * Compatibilita con eventuali job legacy * ancora salvati sotto HLS_BASE. */ const rel = path .relative(HLS_BASE, masterPath) .split(path.sep) .join('/'); publicPath = `/hls/${rel}`; } if (base) { return ( `${base.replace(/\/$/, '')}` + publicPath ); } return publicPath; } // ----------------------------------------------------------------------------- // 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'); } /* * outDir identifica la directory fisica del video Cast. */ const jobDir = path.resolve(outDir); /* * Evita conversioni concorrenti sullo stesso video. * Se esiste già un job attivo o un output pronto, * riutilizza il jobId invece di cancellare i segmenti. */ for (const existingJob of jobs.values()) { if ( path.resolve(existingJob.outDir) !== jobDir || existingJob.inputPath !== inputPath ) { continue; } const masterExists = fs.existsSync( existingJob.masterPath ); const tsCount = countSegments(jobDir); const processRunning = existingJob.exitCode === null && existingJob.status !== 'timeout'; const outputPlayable = existingJob.exitCode === 0 && existingJob.ready && masterExists && tsCount >= 1; if (processRunning || outputPlayable) { console.log( `[job ${existingJob.jobId}] Reuse existing job ` + `for ${jobDir}` ); return existingJob.jobId; } } const jobId = Date.now().toString(); /* * Pulisce i vecchi file soltanto quando non c'è * un job attivo o un output pronto da riutilizzare. */ fs.rmSync(jobDir, { recursive: true, force: true }); fs.mkdirSync(jobDir, { recursive: true }); const useRkmpp = true; 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, { env: FFMPEG_ENV, stdio: [ 'ignore', 'pipe', 'pipe' ] } ); const job = { jobId, inputPath, outDir: jobDir, callbackUrl, segmentsNeeded: Math.max(1, Number(segmentsNeeded)), proc, notified: false, ready: false, status: 'processing', 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); console.log( `[job ${jobId}] HLS progress: segments=${tsCount}, required=${job.segmentsNeeded}` ); if (tsCount >= job.segmentsNeeded) { job.ready = true; job.status = 'ready'; 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; job.status = 'timeout'; 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; const hasPlayableOutput = code === 0 && masterExists && tsCount >= 1; if (hasPlayableOutput) { job.ready = true; } job.status = job.ready ? ( tsCount >= job.segmentsNeeded ? 'ready' : 'finished_short_video' ) : ( code === 0 && masterExists ? 'finished_insufficient_segments' : 'error' ); status = job.status; 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 { job.finishedAt = Date.now(); setTimeout(() => { jobs.delete(jobId); }, 2 * 60 * 1000).unref(); } } ); return jobId; } // ----------------------------------------------------------------------------- // Get job // ----------------------------------------------------------------------------- function getJob(jobId) { return jobs.get( jobId ) || null; } // ----------------------------------------------------------------------------- // Exports // ----------------------------------------------------------------------------- module.exports = { startJob, getJob };