1378 lines
25 KiB
JavaScript
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
|
|
};
|