galleria_photo_face_rknn_op.../helpers/hlsManager.js.ok2
2026-10-03 12:16:58 +02:00

727 lines
13 KiB
Text

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