729 lines
13 KiB
JavaScript
729 lines
13 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 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',
|
|
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');
|
|
|
|
if (useRkmpp) {
|
|
return [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'verbose',
|
|
'-y',
|
|
|
|
// Input probing
|
|
'-probesize',
|
|
'5000000',
|
|
|
|
'-analyzeduration',
|
|
'10000000',
|
|
|
|
// IMPORTANT:
|
|
// Do not use "-hwaccel rkmpp" here.
|
|
// The tested HEVC decoder is explicitly hevc_rkmpp.
|
|
'-c:v',
|
|
'hevc_rkmpp',
|
|
|
|
'-i',
|
|
inputPath,
|
|
|
|
// Streams
|
|
'-map',
|
|
'0:v:0',
|
|
|
|
'-map',
|
|
'0:a:0?',
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Video: Rockchip MPP H.264 encoder
|
|
// ---------------------------------------------------------------------
|
|
|
|
'-c:v',
|
|
'h264_rkmpp',
|
|
|
|
'-rc_mode',
|
|
'CBR',
|
|
|
|
'-b:v',
|
|
'5000k',
|
|
|
|
'-maxrate',
|
|
'6000k',
|
|
|
|
'-bufsize',
|
|
'12000k',
|
|
|
|
// Fixed output framerate
|
|
'-r',
|
|
'30',
|
|
|
|
// 90 frames @ 30 fps = ~3 seconds
|
|
// This matches hls_time=3.
|
|
'-g',
|
|
'90',
|
|
|
|
'-keyint_min',
|
|
'90',
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Audio
|
|
// ---------------------------------------------------------------------
|
|
|
|
'-c:a',
|
|
'aac',
|
|
|
|
'-b:a',
|
|
'160k',
|
|
|
|
'-ac',
|
|
'2',
|
|
|
|
'-ar',
|
|
'48000',
|
|
|
|
// ---------------------------------------------------------------------
|
|
// HLS
|
|
// ---------------------------------------------------------------------
|
|
|
|
'-f',
|
|
'hls',
|
|
|
|
'-hls_time',
|
|
'3',
|
|
|
|
'-hls_list_size',
|
|
'0',
|
|
|
|
'-hls_playlist_type',
|
|
'vod',
|
|
|
|
'-hls_segment_type',
|
|
'mpegts',
|
|
|
|
'-hls_segment_filename',
|
|
segPattern,
|
|
|
|
master
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CPU fallback
|
|
// ---------------------------------------------------------------------------
|
|
|
|
return [
|
|
'-hide_banner',
|
|
'-y',
|
|
|
|
'-probesize',
|
|
'5000000',
|
|
|
|
'-analyzeduration',
|
|
'10000000',
|
|
|
|
'-noautorotate',
|
|
|
|
'-i',
|
|
inputPath,
|
|
|
|
'-map',
|
|
'0:v:0',
|
|
|
|
'-map',
|
|
'0:a:0?',
|
|
|
|
// CPU H.264
|
|
'-c:v',
|
|
'libx264',
|
|
|
|
'-profile:v',
|
|
'high',
|
|
|
|
'-level',
|
|
'4.1',
|
|
|
|
'-preset',
|
|
'veryfast',
|
|
|
|
'-tune',
|
|
'zerolatency',
|
|
|
|
'-b:v',
|
|
'5000k',
|
|
|
|
'-maxrate',
|
|
'6000k',
|
|
|
|
'-bufsize',
|
|
'12000k',
|
|
|
|
'-r',
|
|
'30',
|
|
|
|
'-g',
|
|
'90',
|
|
|
|
'-keyint_min',
|
|
'90',
|
|
|
|
'-sc_threshold',
|
|
'0',
|
|
|
|
'-pix_fmt',
|
|
'yuv420p',
|
|
|
|
// Audio
|
|
'-c:a',
|
|
'aac',
|
|
|
|
'-b:a',
|
|
'160k',
|
|
|
|
'-ac',
|
|
'2',
|
|
|
|
'-ar',
|
|
'48000',
|
|
|
|
// HLS
|
|
'-f',
|
|
'hls',
|
|
|
|
'-hls_time',
|
|
'3',
|
|
|
|
'-hls_list_size',
|
|
'0',
|
|
|
|
'-hls_playlist_type',
|
|
'vod',
|
|
|
|
'-hls_segment_type',
|
|
'mpegts',
|
|
|
|
'-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 rel = path
|
|
.relative(HLS_BASE, masterPath)
|
|
.split(path.sep)
|
|
.join('/');
|
|
|
|
if (base) {
|
|
return `${base.replace(/\/$/, '')}/hls/${rel}`;
|
|
}
|
|
|
|
return `/${rel}`;
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 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');
|
|
}
|
|
|
|
const jobId = Date.now().toString();
|
|
|
|
const jobDir = path.join(
|
|
outDir,
|
|
jobId
|
|
);
|
|
|
|
fs.mkdirSync(
|
|
jobDir,
|
|
{
|
|
recursive: true
|
|
}
|
|
);
|
|
|
|
const useRkmpp = hasRkmpp();
|
|
|
|
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,
|
|
{
|
|
stdio: [
|
|
'ignore',
|
|
'pipe',
|
|
'pipe'
|
|
]
|
|
}
|
|
);
|
|
|
|
const job = {
|
|
jobId,
|
|
|
|
inputPath,
|
|
|
|
outDir: jobDir,
|
|
|
|
callbackUrl,
|
|
|
|
segmentsNeeded:
|
|
Math.max(1, Number(segmentsNeeded)),
|
|
|
|
proc,
|
|
|
|
notified: false,
|
|
|
|
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);
|
|
|
|
if (
|
|
tsCount >=
|
|
job.segmentsNeeded
|
|
) {
|
|
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;
|
|
|
|
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;
|
|
|
|
let status;
|
|
|
|
if (code === 0 && masterExists) {
|
|
status = 'finished';
|
|
} else {
|
|
status = 'error';
|
|
}
|
|
|
|
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 {
|
|
jobs.delete(
|
|
jobId
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
return jobId;
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Get job
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function getJob(jobId) {
|
|
return jobs.get(
|
|
jobId
|
|
) || null;
|
|
}
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Exports
|
|
// -----------------------------------------------------------------------------
|
|
|
|
module.exports = {
|
|
startJob,
|
|
getJob
|
|
};
|