gallery_server_client_s190js/helpers/hlsManager.js
2026-10-26 11:00:14 +01:00

1378 lines
25 KiB
JavaScript

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