// 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 FFPROBE_BIN = '/usr/local/bin/ffprobe'; /* * 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 // ----------------------------------------------------------------------------- /* * Legge la rotazione dichiarata nel contenitore video. * * Restituisce uno dei valori: * 0, 90, -90, 180 * * In caso di errore assume rotazione zero. */ function getVideoRotation(inputPath) { try { const output = execFileSync( FFPROBE_BIN, [ '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream_tags=rotate:stream_side_data=rotation', '-of', 'json', inputPath ], { encoding: 'utf8', env: FFMPEG_ENV, stdio: [ 'ignore', 'pipe', 'pipe' ] } ); const parsed = JSON.parse(output); const stream = parsed.streams?.[0] || {}; let rotation = null; if (Array.isArray(stream.side_data_list)) { for (const sideData of stream.side_data_list) { if ( sideData && sideData.rotation !== undefined ) { rotation = Number(sideData.rotation); break; } } } if ( rotation === null && stream.tags && stream.tags.rotate !== undefined ) { rotation = Number(stream.tags.rotate); } if (!Number.isFinite(rotation)) { return 0; } /* * Normalizza, ad esempio: * 270 -> -90 * -270 -> 90 */ rotation = ((Math.round(rotation) % 360) + 360) % 360; if (rotation === 90) { return 90; } if (rotation === 180) { return 180; } if (rotation === 270) { return -90; } return 0; } catch (err) { console.warn( `[hlsManager] Cannot detect rotation for ` + `${inputPath}: ${err.message}` ); return 0; } } function buildFfmpegArgs(inputPath, jobDir, useRkmpp) { /* * FFmpeg aggiorna esclusivamente la playlist interna. * * Il Chromecast riceve master.m3u8, che viene pubblicata * separatamente come VOD anticipata da hlsManager. */ const internalMaster = path.join( jobDir, 'internal.m3u8' ); const segPattern = path.join( jobDir, 'segment_%05d.ts' ); const rotation = getVideoRotation(inputPath); console.log( `[hlsManager] video rotation: ${rotation}` ); /* * Pipeline completamente software: * * decode software * -> rotazione software, se necessaria * -> scale software * -> pad software * -> encode libx264 ultrafast * * Il risultato è sempre 1280x720, con aspect ratio * originale preservato e bande nere quando necessarie. */ let rotationFilter = ''; if (rotation === -90) { /* * Verificato sui file con metadata rotation=-90: * la trasformazione correttiva è clockwise. */ rotationFilter = 'transpose=clock,'; } else if (rotation === 90) { rotationFilter = 'transpose=cclock,'; } else if (rotation === 180) { rotationFilter = 'hflip,vflip,'; } const videoFilter = rotationFilter + 'scale=1280:720:' + 'force_original_aspect_ratio=decrease:' + 'force_divisible_by=2,' + 'pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,' + 'setsar=1,' + 'format=yuv420p'; return [ '-hide_banner', '-loglevel', 'warning', '-y', '-probesize', '5000000', '-analyzeduration', '10000000', /* * Disattiva l'autorotazione automatica. * La rotazione viene applicata esplicitamente nel filtro. */ '-noautorotate', /* * Nessun -hwaccel: il decoder resta software. */ '-i', inputPath, '-map', '0:v:0', '-map', '0:a:0?', '-vf', videoFilter, /* * Encoder completamente software. */ '-c:v', 'libx264', '-profile:v', 'high', '-level:v', '4.1', '-preset', 'ultrafast', '-tune', 'zerolatency', '-b:v', '5000k', '-maxrate', '6000k', '-bufsize', '12000k', '-r', '30', '-g', '90', '-keyint_min', '90', '-sc_threshold', '0', /* * La rotazione è incorporata fisicamente nei frame. */ '-metadata:s:v:0', 'rotate=0', '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000', '-f', 'hls', '-hls_time', '3', '-hls_list_size', '0', /* * La playlist interna può crescere durante la conversione. * Non viene esposta direttamente al Chromecast. */ '-hls_playlist_type', 'event', '-hls_segment_type', 'mpegts', '-hls_flags', 'independent_segments', '-hls_segment_filename', segPattern, internalMaster ]; } // ----------------------------------------------------------------------------- // 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 // ----------------------------------------------------------------------------- /* * Inserisce nella playlist HLS il punto di partenza preferito. * * Questo evita che un client HLS EVENT scelga automaticamente * un punto vicino al bordo più recente della playlist. * * La scrittura avviene su un file temporaneo e il rename finale * rende l'aggiornamento atomico. */ function ensureHlsStartsAtBeginning(masterPath) { if (!fs.existsSync(masterPath)) { return false; } const marker = '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES'; const original = fs.readFileSync( masterPath, 'utf8' ); if (original.includes(marker)) { return true; } const lines = original.split(/\r?\n/); const versionIndex = lines.findIndex( line => line.startsWith( '#EXT-X-VERSION:' ) ); const insertIndex = versionIndex >= 0 ? versionIndex + 1 : 1; lines.splice( insertIndex, 0, marker ); const temporaryPath = `${masterPath}.start.tmp`; fs.writeFileSync( temporaryPath, lines.join('\n') ); fs.renameSync( temporaryPath, masterPath ); console.log( `[hlsManager] Added EXT-X-START to ${masterPath}` ); return true; } /* * Quando FFmpeg ha completato la playlist e ha scritto ENDLIST, * converte il manifest EVENT in un manifest VOD definitivo. * * Anche questa modifica viene eseguita atomicamente. */ function finalizeHlsAsVod(masterPath) { if (!fs.existsSync(masterPath)) { return false; } const original = fs.readFileSync( masterPath, 'utf8' ); if ( !original.includes( '#EXT-X-ENDLIST' ) ) { console.warn( `[hlsManager] Cannot finalize HLS as VOD: ` + `EXT-X-ENDLIST is missing in ${masterPath}` ); return false; } let finalized = original; if ( finalized.includes( '#EXT-X-PLAYLIST-TYPE:EVENT' ) ) { finalized = finalized.replace( '#EXT-X-PLAYLIST-TYPE:EVENT', '#EXT-X-PLAYLIST-TYPE:VOD' ); } const temporaryPath = `${masterPath}.vod.tmp`; fs.writeFileSync( temporaryPath, finalized ); fs.renameSync( temporaryPath, masterPath ); console.log( `[hlsManager] Finalized HLS as VOD: ${masterPath}` ); return true; } /* * Restituisce la durata completa del contenuto in secondi. * * La durata viene usata per costruire anticipatamente una playlist * VOD completa, anche se FFmpeg sta ancora producendo i segmenti. */ function getVideoDurationSeconds(inputPath) { try { const output = execFileSync( FFPROBE_BIN, [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', inputPath ], { encoding: 'utf8', env: FFMPEG_ENV, stdio: [ 'ignore', 'pipe', 'pipe' ] } ); const duration = Number.parseFloat(output.trim()); if ( !Number.isFinite(duration) || duration <= 0 ) { throw new Error( `invalid duration: ${output.trim()}` ); } return duration; } catch (err) { console.error( `[hlsManager] Cannot detect video duration for ` + `${inputPath}: ${err.message}` ); return null; } } /* * Crea il manifest VOD anticipato esposto al Chromecast. * * Al momento della pubblicazione esistono soltanto i primi segmenti, * ma il manifest dichiara già l'intera timeline. * * FFmpeg continua a produrre i segmenti usando internal.m3u8. */ function publishAnticipatedVodPlaylist(job) { if ( !Number.isFinite(job.durationSeconds) || job.durationSeconds <= 0 ) { console.error( `[job ${job.jobId}] Cannot create anticipated VOD: ` + `invalid duration` ); return false; } const segmentDuration = 3; const totalSegments = Math.ceil( job.durationSeconds / segmentDuration ); let lastDuration = job.durationSeconds - segmentDuration * (totalSegments - 1); if ( !Number.isFinite(lastDuration) || lastDuration <= 0 ) { lastDuration = segmentDuration; } const lines = [ '#EXTM3U', '#EXT-X-VERSION:6', '#EXT-X-TARGETDURATION:3', '#EXT-X-MEDIA-SEQUENCE:0', '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES', '#EXT-X-PLAYLIST-TYPE:VOD', '#EXT-X-INDEPENDENT-SEGMENTS' ]; for ( let index = 0; index < totalSegments; index++ ) { const duration = index < totalSegments - 1 ? segmentDuration : lastDuration; lines.push( `#EXTINF:${duration.toFixed(6)},` ); lines.push( `segment_${String(index).padStart(5, '0')}.ts` ); } lines.push( '#EXT-X-ENDLIST' ); const temporaryPath = `${job.masterPath}.anticipated.tmp`; fs.writeFileSync( temporaryPath, `${lines.join('\n')}\n`, 'utf8' ); fs.renameSync( temporaryPath, job.masterPath ); job.anticipatedPlaylistPublished = true; job.expectedSegments = totalSegments; console.log( `[job ${job.jobId}] Anticipated VOD published: ` + `ready=${countSegments(job.outDir)}, ` + `declared=${totalSegments}, ` + `duration=${job.durationSeconds.toFixed(3)}` ); return true; } /* * Alla fine della transcodifica sostituisce la playlist anticipata * con quella realmente prodotta da FFmpeg. * * La pubblicazione è atomica e conserva lo stesso URL master.m3u8. */ function publishFinalVodPlaylist(job) { if ( !fs.existsSync(job.internalMasterPath) ) { console.error( `[job ${job.jobId}] Internal playlist missing: ` + `${job.internalMasterPath}` ); return false; } let content = fs.readFileSync( job.internalMasterPath, 'utf8' ); if ( !content.includes('#EXT-X-ENDLIST') ) { console.error( `[job ${job.jobId}] Internal playlist has no ENDLIST` ); return false; } content = content.replace( '#EXT-X-PLAYLIST-TYPE:EVENT', '#EXT-X-PLAYLIST-TYPE:VOD' ); const startMarker = '#EXT-X-START:TIME-OFFSET=0,PRECISE=YES'; if (!content.includes(startMarker)) { const lines = content.split(/\r?\n/); const versionIndex = lines.findIndex( line => line.startsWith( '#EXT-X-VERSION:' ) ); lines.splice( versionIndex >= 0 ? versionIndex + 1 : 1, 0, startMarker ); content = lines.join('\n'); } if (!content.endsWith('\n')) { content += '\n'; } const temporaryPath = `${job.masterPath}.final.tmp`; fs.writeFileSync( temporaryPath, content, 'utf8' ); fs.renameSync( temporaryPath, job.masterPath ); console.log( `[job ${job.jobId}] Final VOD playlist published: ` + `${job.masterPath}` ); return true; } 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 = false; const durationSeconds = getVideoDurationSeconds( inputPath ); 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, durationSeconds, /* * Playlist pubblica esposta al Chromecast. */ masterPath: path.join( jobDir, 'master.m3u8' ), /* * Playlist interna aggiornata direttamente da FFmpeg. */ internalMasterPath: path.join( jobDir, 'internal.m3u8' ), anticipatedPlaylistPublished: false, expectedSegments: null, 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.internalMasterPath) ) { const tsCount = countSegments(job.outDir); console.log( `[job ${jobId}] HLS progress: segments=${tsCount}, required=${job.segmentsNeeded}` ); if (tsCount >= job.segmentsNeeded) { if ( !job.anticipatedPlaylistPublished && !publishAnticipatedVodPlaylist(job) ) { throw new Error( 'Cannot publish anticipated VOD playlist' ); } /* * Prima di comunicare che l'HLS è pronto, * forza il punto di partenza al primo segmento. */ ensureHlsStartsAtBeginning( job.masterPath ); 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 { let masterExists = fs.existsSync( job.masterPath ); const tsCount = countSegments( job.outDir ); /* * Al termine sostituisce atomicamente la playlist VOD * anticipata con il manifest reale prodotto da FFmpeg. */ if ( code === 0 && fs.existsSync(job.internalMasterPath) ) { publishFinalVodPlaylist( job ); /* * La playlist pubblica può essere stata creata soltanto * ora, specialmente per i video più corti della soglia * iniziale di segmentsNeeded. */ masterExists = fs.existsSync( job.masterPath ); } 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 };