fix: SRT/RTMP ingest + thumbnail crashes #1

Closed
zgaetano wants to merge 1 commit from fix/srt-rtmp-thumbnail into main
5 changed files with 166 additions and 47 deletions

View file

@ -2,25 +2,125 @@ import express from 'express';
import cors from 'cors'; import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import captureRoutes from './routes/capture.js'; import captureRoutes from './routes/capture.js';
import captureManager from './capture-manager.js';
dotenv.config(); dotenv.config();
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
// Middleware
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// Health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ status: 'ok' }); res.json({ status: 'ok' });
}); });
// Routes
app.use('/capture', captureRoutes); app.use('/capture', captureRoutes);
// Start server const server = app.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`); console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
bootstrapAutoStart().catch((err) => {
console.error('[bootstrap] auto-start failed:', err);
}); });
});
async function bootstrapAutoStart() {
const recorderId = process.env.RECORDER_ID;
const sourceType = process.env.SOURCE_TYPE;
if (!recorderId || !sourceType) {
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
return;
}
const projectId = process.env.PROJECT_ID;
const clipName = process.env.CLIP_NAME;
if (!projectId || !clipName) {
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
return;
}
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
const listenPort = process.env.LISTEN_PORT
? parseInt(process.env.LISTEN_PORT, 10)
: undefined;
const streamKey = process.env.STREAM_KEY || undefined;
const sourceUrl = process.env.SOURCE_URL || undefined;
if (sourceType === 'sdi') {
console.warn('[bootstrap] SDI auto-start not supported');
return;
}
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try {
const session = await captureManager.start({
projectId,
binId: process.env.BIN_ID || null,
clipName,
sourceType,
sourceUrl,
listen,
listenPort,
streamKey,
});
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
} catch (err) {
console.error('[bootstrap] failed to start capture:', err);
}
}
let shuttingDown = false;
async function gracefulShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received`);
const status = captureManager.getStatus();
if (status.recording) {
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
try {
const completed = await captureManager.stop(status.sessionId);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
}
} catch (err) {
console.error('[shutdown] error during stop:', err);
}
}
server.close(() => {
console.log('[shutdown] http server closed - exiting');
process.exit(0);
});
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View file

@ -19,6 +19,10 @@ const thumbnailQueue = new Queue('thumbnail', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'), connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
}); });
const proxyQueue = new Queue('proxy', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// GET / - List assets with filtering // GET / - List assets with filtering
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
@ -145,6 +149,12 @@ router.post('/', async (req, res, next) => {
proxyKey, proxyKey,
outputKey: thumbnailKey, outputKey: thumbnailKey,
}); });
} else if (hiresKey) {
await proxyQueue.add('generate', {
assetId: id,
inputKey: hiresKey,
outputKey: `proxies/${id}.mp4`,
});
} else { } else {
// No proxy yet — mark ready immediately (e.g. audio-only or test mode) // No proxy yet — mark ready immediately (e.g. audio-only or test mode)
await pool.query( await pool.query(

View file

@ -86,17 +86,16 @@ router.get('/', async (req, res, next) => {
// POST / - Create a new recorder // POST / - Create a new recorder
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { const b = req.body || {};
name, const name = b.name;
source_type, const source_type = b.source_type;
source_config, const source_config = b.source_config;
recording_codec, const recording_codec = b.recording_codec || b.codec;
recording_resolution, const recording_resolution = b.recording_resolution || b.resolution;
proxy_enabled, const proxy_enabled = b.proxy_enabled !== undefined ? b.proxy_enabled : (b.proxy_config ? true : undefined);
proxy_codec, const proxy_codec = b.proxy_codec || (b.proxy_config && b.proxy_config.codec);
proxy_resolution, const proxy_resolution = b.proxy_resolution || (b.proxy_config && (b.proxy_config.resolution || b.proxy_config.bitrate));
project_id, const project_id = b.project_id;
} = req.body;
if (!name || !source_type) { if (!name || !source_type) {
return res return res
@ -268,6 +267,13 @@ router.post('/:id/start', async (req, res, next) => {
const startRes = await dockerApi('POST', `/containers/${containerId}/start`); const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) { if (startRes.status !== 204) {
// Clean up the unstarted container so it doesn't accumulate as an orphan
// (e.g. when the requested host port is already bound by another process).
try {
await dockerApi('DELETE', `/containers/${containerId}?force=true`);
} catch (cleanupErr) {
console.error('Failed to remove unstarted container:', cleanupErr.message);
}
return res.status(500).json({ return res.status(500).json({
error: 'Failed to start container', error: 'Failed to start container',
details: startRes.data, details: startRes.data,
@ -310,10 +316,10 @@ router.post('/:id/stop', async (req, res, next) => {
return res.status(400).json({ error: 'No container running' }); return res.status(400).json({ error: 'No container running' });
} }
// Stop container // Stop container with 5-min grace so SRT/RTMP captures can flush S3 upload
const stopRes = await dockerApi( const stopRes = await dockerApi(
'POST', 'POST',
`/containers/${recorder.container_id}/stop` `/containers/${recorder.container_id}/stop?t=300`
); );
// 204 = stopped, 304 = already stopped — both are acceptable // 204 = stopped, 304 = already stopped — both are acceptable

View file

@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recorders — Wild Dragon</title> <title>Recorders — Z-AMPP</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/common.css"> <link rel="stylesheet" href="css/common.css?v=3">
<style> <style>
/* Recorder grid */ /* Recorder grid */
.recorder-grid { .recorder-grid {
@ -191,10 +191,8 @@
<!-- Sidebar --> <!-- Sidebar -->
<nav class="sidebar" aria-label="Main navigation"> <nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="sidebar-brand-mark"> <img src="img/dragon-mark.png" alt="Z-AMPP" class="sidebar-logo">
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"><path d="M8 1L2 5v6l6 4 6-4V5L8 1zm0 2.2L12 6v4l-4 2.7L4 10V6l4-2.8z"/></svg> <span class="sidebar-brand-name">Z-AMPP</span>
</div>
<span class="sidebar-brand-name">Wild Dragon</span>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="index.html" class="nav-item"> <a href="index.html" class="nav-item">
@ -298,9 +296,9 @@
<label class="form-label" for="recResolution">Resolution</label> <label class="form-label" for="recResolution">Resolution</label>
<select id="recResolution"> <select id="recResolution">
<option value="native">Native (source)</option> <option value="native">Native (source)</option>
<option value="1920x1080">1920x1080</option> <option value="1920x1080">1920×1080</option>
<option value="1280x720">1280x720</option> <option value="1280x720">1280×720</option>
<option value="3840x2160">3840x2160</option> <option value="3840x2160">3840×2160</option>
</select> </select>
</div> </div>
</div> </div>
@ -378,7 +376,7 @@
updateSourceFields(); updateSourceFields();
}); });
// Load / render // ── Load / render ─────────────────────────
async function loadRecorders() { async function loadRecorders() {
const r = await getRecorders(); const r = await getRecorders();
if (!r.success) return; if (!r.success) return;
@ -405,7 +403,7 @@
let sourceDisplay = ''; let sourceDisplay = '';
if (cfg.mode === 'listener') { if (cfg.mode === 'listener') {
const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 9000 : 1935); const port = cfg.listen_port || (sourceTypeKey === 'srt' ? 49001 : 41936);
sourceDisplay = `Listen :${port}`; sourceDisplay = `Listen :${port}`;
} else if (cfg.url) { } else if (cfg.url) {
sourceDisplay = cfg.url; sourceDisplay = cfg.url;
@ -417,13 +415,13 @@
if (!isRecording && cfg.mode === 'listener') { if (!isRecording && cfg.mode === 'listener') {
const serverIp = location.hostname || '10.0.0.25'; const serverIp = location.hostname || '10.0.0.25';
if (sourceTypeKey === 'srt') { if (sourceTypeKey === 'srt') {
const port = cfg.listen_port || 9000; const port = cfg.listen_port || 49001;
connectBanner = `<div class="info-banner recorder-connect-info"> connectBanner = `<div class="info-banner recorder-connect-info">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span> <span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
</div>`; </div>`;
} else if (sourceTypeKey === 'rtmp') { } else if (sourceTypeKey === 'rtmp') {
const port = cfg.listen_port || 1935; const port = cfg.listen_port || 41936;
const key = cfg.stream_key || 'stream'; const key = cfg.stream_key || 'stream';
connectBanner = `<div class="info-banner recorder-connect-info"> connectBanner = `<div class="info-banner recorder-connect-info">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
@ -480,6 +478,7 @@
</div>`; </div>`;
}).join(''); }).join('');
// Start timers for recording recorders
pState.recorders.filter(r => r.status === 'recording').forEach(rec => { pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
if (!pState.timers[rec.id]) { if (!pState.timers[rec.id]) {
const startedAt = rec.started_at ? new Date(rec.started_at) : new Date(); const startedAt = rec.started_at ? new Date(rec.started_at) : new Date();
@ -503,7 +502,7 @@
return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':'); return [h, m, sec].map(v => String(v).padStart(2,'0')).join(':');
} }
// Controls // ── Controls ──────────────────────────────
async function handleStart(id) { async function handleStart(id) {
const r = await startRecorder(id); const r = await startRecorder(id);
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); } if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
@ -523,7 +522,7 @@
else toast('Delete failed', r.error, 'error'); else toast('Delete failed', r.error, 'error');
} }
// Panel // ── Panel ─────────────────────────────────
function openPanel() { function openPanel() {
document.getElementById('recorderPanel').classList.add('open'); document.getElementById('recorderPanel').classList.add('open');
document.getElementById('panelOverlay').classList.add('open'); document.getElementById('panelOverlay').classList.add('open');
@ -535,7 +534,7 @@
document.getElementById('panelOverlay').classList.remove('open'); document.getElementById('panelOverlay').classList.remove('open');
} }
// Source type // ── Source type ───────────────────────────
function setSourceType(type) { function setSourceType(type) {
pState.sourceType = type; pState.sourceType = type;
pState.mode = 'listener'; pState.mode = 'listener';
@ -563,19 +562,19 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Mode</label> <label class="form-label">Mode</label>
<div class="mode-row"> <div class="mode-row">
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button> <button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener encoder pushes here</button>
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button> <button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller pull from source</button>
</div> </div>
</div> </div>
<div id="srtListenerFields"> <div id="srtListenerFields">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="srtPort">Listen port (UDP)</label> <label class="form-label" for="srtPort">Listen port (UDP)</label>
<input type="number" id="srtPort" value="9000" min="1024" max="65535"> <input type="number" id="srtPort" value="49001" min="1024" max="65535">
<div class="form-hint">Encoders connect to this port on the server</div> <div class="form-hint">Encoders connect to this port on the server</div>
</div> </div>
<div id="srtConnectInfo" class="info-banner"> <div id="srtConnectInfo" class="info-banner">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
<span>Encoder connect string: <code id="srtConnectStr">srt://10.0.0.25:9000?mode=caller</code></span> <span>Encoder connect string: <code id="srtConnectStr">srt://${location.hostname || '10.0.0.25'}:49001?mode=caller</code></span>
</div> </div>
</div> </div>
<div id="srtCallerFields" style="display:none;"> <div id="srtCallerFields" style="display:none;">
@ -585,6 +584,7 @@
</div> </div>
</div>`; </div>`;
// Wire port input to update banner
setTimeout(() => { setTimeout(() => {
const portIn = document.getElementById('srtPort'); const portIn = document.getElementById('srtPort');
if (portIn) portIn.addEventListener('input', () => { if (portIn) portIn.addEventListener('input', () => {
@ -598,15 +598,15 @@
<div class="form-group"> <div class="form-group">
<label class="form-label">Mode</label> <label class="form-label">Mode</label>
<div class="mode-row"> <div class="mode-row">
<button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener - encoder pushes here</button> <button class="mode-btn active" data-mode="listener" onclick="setMode('listener')">Listener encoder pushes here</button>
<button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller - pull from source</button> <button class="mode-btn" data-mode="caller" onclick="setMode('caller')">Caller pull from source</button>
</div> </div>
</div> </div>
<div id="rtmpListenerFields"> <div id="rtmpListenerFields">
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="rtmpPort">Listen port (TCP)</label> <label class="form-label" for="rtmpPort">Listen port (TCP)</label>
<input type="number" id="rtmpPort" value="1935" min="1024" max="65535"> <input type="number" id="rtmpPort" value="41936" min="1024" max="65535">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="rtmpKey">Stream key</label> <label class="form-label" for="rtmpKey">Stream key</label>
@ -615,7 +615,7 @@
</div> </div>
<div id="rtmpConnectInfo" class="info-banner"> <div id="rtmpConnectInfo" class="info-banner">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="6"/><path d="M7 4v4M7 9.5v.5"/></svg>
<span>Push to: <code id="rtmpConnectStr">rtmp://10.0.0.25:1935/live/stream</code></span> <span>Push to: <code id="rtmpConnectStr">rtmp://${location.hostname || '10.0.0.25'}:41936/live/stream</code></span>
</div> </div>
</div> </div>
<div id="rtmpCallerFields" style="display:none;"> <div id="rtmpCallerFields" style="display:none;">
@ -630,7 +630,7 @@
const keyIn = document.getElementById('rtmpKey'); const keyIn = document.getElementById('rtmpKey');
const update = () => { const update = () => {
const el = document.getElementById('rtmpConnectStr'); const el = document.getElementById('rtmpConnectStr');
if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 1935}/live/${keyIn?.value || 'stream'}`; if (el) el.textContent = `rtmp://${location.hostname || '10.0.0.25'}:${portIn?.value || 41936}/live/${keyIn?.value || 'stream'}`;
}; };
portIn?.addEventListener('input', update); portIn?.addEventListener('input', update);
keyIn?.addEventListener('input', update); keyIn?.addEventListener('input', update);
@ -651,7 +651,7 @@
} }
} }
// Projects for recorder destination // ── Projects for recorder destination ─────
async function loadProjects() { async function loadProjects() {
const r = await getProjects(); const r = await getProjects();
if (!r.success) return; if (!r.success) return;
@ -670,7 +670,7 @@
if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`); if (r.success) r.data.forEach(b => binSel.innerHTML += `<option value="${b.id}">${esc(b.name)}</option>`);
} }
// Save recorder // ── Save recorder ─────────────────────────
async function handleSaveRecorder() { async function handleSaveRecorder() {
const name = document.getElementById('recName').value.trim(); const name = document.getElementById('recName').value.trim();
if (!name) { toast('Enter a recorder name', '', 'warning'); return; } if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
@ -687,12 +687,12 @@
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0'); sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
} else if (type === 'srt') { } else if (type === 'srt') {
sourceConfig.mode = mode; sourceConfig.mode = mode;
if (mode === 'listener') sourceConfig.listen_port = parseInt(document.getElementById('srtPort')?.value || '9000'); if (mode === 'listener') sourceConfig.listen_port = parseInt(document.getElementById('srtPort')?.value || '49001');
else sourceConfig.url = document.getElementById('srtUrl')?.value; else sourceConfig.url = document.getElementById('srtUrl')?.value;
} else if (type === 'rtmp') { } else if (type === 'rtmp') {
sourceConfig.mode = mode; sourceConfig.mode = mode;
if (mode === 'listener') { if (mode === 'listener') {
sourceConfig.listen_port = parseInt(document.getElementById('rtmpPort')?.value || '1935'); sourceConfig.listen_port = parseInt(document.getElementById('rtmpPort')?.value || '41936');
sourceConfig.stream_key = document.getElementById('rtmpKey')?.value || 'stream'; sourceConfig.stream_key = document.getElementById('rtmpKey')?.value || 'stream';
} else { } else {
sourceConfig.url = document.getElementById('rtmpUrl')?.value; sourceConfig.url = document.getElementById('rtmpUrl')?.value;

View file

@ -41,6 +41,7 @@ export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
'-ss', timeCode, '-ss', timeCode,
'-i', inputPath, '-i', inputPath,
'-vframes', '1', '-vframes', '1',
'-pix_fmt', 'yuvj420p',
'-q:v', '2', '-q:v', '2',
'-y', '-y',
outputPath, outputPath,
@ -48,6 +49,7 @@ export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
await runFFmpeg(args); await runFFmpeg(args);
}; };
// PATCHED for browser-compat pix_fmt
export const transcodeVideo = async (inputPath, outputPath, options = {}) => { export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
const { const {
videoCodec = 'libx264', videoCodec = 'libx264',
@ -64,6 +66,7 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
'-b:v', videoBitrate, '-b:v', videoBitrate,
'-c:a', audioCodec, '-c:a', audioCodec,
'-b:a', audioBitrate, '-b:a', audioBitrate,
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart', '-movflags', '+faststart',
'-y', '-y',
outputPath, outputPath,