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

843 lines
15 KiB
JavaScript

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