fix: SRT/RTMP ingest + thumbnail crashes #1
5 changed files with 166 additions and 47 deletions
|
|
@ -2,25 +2,125 @@ import express from 'express';
|
|||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import captureRoutes from './routes/capture.js';
|
||||
import captureManager from './capture-manager.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
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(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/capture', captureRoutes);
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
const server = app.listen(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'));
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ const thumbnailQueue = new Queue('thumbnail', {
|
|||
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
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
|
|
@ -145,6 +149,12 @@ router.post('/', async (req, res, next) => {
|
|||
proxyKey,
|
||||
outputKey: thumbnailKey,
|
||||
});
|
||||
} else if (hiresKey) {
|
||||
await proxyQueue.add('generate', {
|
||||
assetId: id,
|
||||
inputKey: hiresKey,
|
||||
outputKey: `proxies/${id}.mp4`,
|
||||
});
|
||||
} else {
|
||||
// No proxy yet — mark ready immediately (e.g. audio-only or test mode)
|
||||
await pool.query(
|
||||
|
|
|
|||
|
|
@ -86,17 +86,16 @@ router.get('/', async (req, res, next) => {
|
|||
// POST / - Create a new recorder
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
source_type,
|
||||
source_config,
|
||||
recording_codec,
|
||||
recording_resolution,
|
||||
proxy_enabled,
|
||||
proxy_codec,
|
||||
proxy_resolution,
|
||||
project_id,
|
||||
} = req.body;
|
||||
const b = req.body || {};
|
||||
const name = b.name;
|
||||
const source_type = b.source_type;
|
||||
const source_config = b.source_config;
|
||||
const recording_codec = b.recording_codec || b.codec;
|
||||
const recording_resolution = b.recording_resolution || b.resolution;
|
||||
const proxy_enabled = b.proxy_enabled !== undefined ? b.proxy_enabled : (b.proxy_config ? true : undefined);
|
||||
const proxy_codec = b.proxy_codec || (b.proxy_config && b.proxy_config.codec);
|
||||
const proxy_resolution = b.proxy_resolution || (b.proxy_config && (b.proxy_config.resolution || b.proxy_config.bitrate));
|
||||
const project_id = b.project_id;
|
||||
|
||||
if (!name || !source_type) {
|
||||
return res
|
||||
|
|
@ -268,6 +267,13 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
|
||||
|
||||
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({
|
||||
error: 'Failed to start container',
|
||||
details: startRes.data,
|
||||
|
|
@ -310,10 +316,10 @@ router.post('/:id/stop', async (req, res, next) => {
|
|||
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(
|
||||
'POST',
|
||||
`/containers/${recorder.container_id}/stop`
|
||||
`/containers/${recorder.container_id}/stop?t=300`
|
||||
);
|
||||
|
||||
// 204 = stopped, 304 = already stopped — both are acceptable
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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.gstatic.com" crossorigin>
|
||||
<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>
|
||||
/* Recorder grid */
|
||||
.recorder-grid {
|
||||
|
|
@ -191,10 +191,8 @@
|
|||
<!-- Sidebar -->
|
||||
<nav class="sidebar" aria-label="Main navigation">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-brand-mark">
|
||||
<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>
|
||||
</div>
|
||||
<span class="sidebar-brand-name">Wild Dragon</span>
|
||||
<img src="img/dragon-mark.png" alt="Z-AMPP" class="sidebar-logo">
|
||||
<span class="sidebar-brand-name">Z-AMPP</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
|
|
@ -298,9 +296,9 @@
|
|||
<label class="form-label" for="recResolution">Resolution</label>
|
||||
<select id="recResolution">
|
||||
<option value="native">Native (source)</option>
|
||||
<option value="1920x1080">1920x1080</option>
|
||||
<option value="1280x720">1280x720</option>
|
||||
<option value="3840x2160">3840x2160</option>
|
||||
<option value="1920x1080">1920×1080</option>
|
||||
<option value="1280x720">1280×720</option>
|
||||
<option value="3840x2160">3840×2160</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -378,7 +376,7 @@
|
|||
updateSourceFields();
|
||||
});
|
||||
|
||||
// Load / render
|
||||
// ── Load / render ─────────────────────────
|
||||
async function loadRecorders() {
|
||||
const r = await getRecorders();
|
||||
if (!r.success) return;
|
||||
|
|
@ -405,7 +403,7 @@
|
|||
|
||||
let sourceDisplay = '';
|
||||
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}`;
|
||||
} else if (cfg.url) {
|
||||
sourceDisplay = cfg.url;
|
||||
|
|
@ -417,13 +415,13 @@
|
|||
if (!isRecording && cfg.mode === 'listener') {
|
||||
const serverIp = location.hostname || '10.0.0.25';
|
||||
if (sourceTypeKey === 'srt') {
|
||||
const port = cfg.listen_port || 9000;
|
||||
const port = cfg.listen_port || 49001;
|
||||
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>
|
||||
<span>Push to <code>srt://${serverIp}:${port}?mode=caller</code></span>
|
||||
</div>`;
|
||||
} else if (sourceTypeKey === 'rtmp') {
|
||||
const port = cfg.listen_port || 1935;
|
||||
const port = cfg.listen_port || 41936;
|
||||
const key = cfg.stream_key || 'stream';
|
||||
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>
|
||||
|
|
@ -480,6 +478,7 @@
|
|||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Start timers for recording recorders
|
||||
pState.recorders.filter(r => r.status === 'recording').forEach(rec => {
|
||||
if (!pState.timers[rec.id]) {
|
||||
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(':');
|
||||
}
|
||||
|
||||
// Controls
|
||||
// ── Controls ──────────────────────────────
|
||||
async function handleStart(id) {
|
||||
const r = await startRecorder(id);
|
||||
if (r.success) { toast('Recording started', '', 'success'); loadRecorders(); }
|
||||
|
|
@ -523,7 +522,7 @@
|
|||
else toast('Delete failed', r.error, 'error');
|
||||
}
|
||||
|
||||
// Panel
|
||||
// ── Panel ─────────────────────────────────
|
||||
function openPanel() {
|
||||
document.getElementById('recorderPanel').classList.add('open');
|
||||
document.getElementById('panelOverlay').classList.add('open');
|
||||
|
|
@ -535,7 +534,7 @@
|
|||
document.getElementById('panelOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
// Source type
|
||||
// ── Source type ───────────────────────────
|
||||
function setSourceType(type) {
|
||||
pState.sourceType = type;
|
||||
pState.mode = 'listener';
|
||||
|
|
@ -563,19 +562,19 @@
|
|||
<div class="form-group">
|
||||
<label class="form-label">Mode</label>
|
||||
<div class="mode-row">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="srtListenerFields">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<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>
|
||||
<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 id="srtCallerFields" style="display:none;">
|
||||
|
|
@ -585,6 +584,7 @@
|
|||
</div>
|
||||
</div>`;
|
||||
|
||||
// Wire port input to update banner
|
||||
setTimeout(() => {
|
||||
const portIn = document.getElementById('srtPort');
|
||||
if (portIn) portIn.addEventListener('input', () => {
|
||||
|
|
@ -598,15 +598,15 @@
|
|||
<div class="form-group">
|
||||
<label class="form-label">Mode</label>
|
||||
<div class="mode-row">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rtmpListenerFields">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label class="form-label" for="rtmpKey">Stream key</label>
|
||||
|
|
@ -615,7 +615,7 @@
|
|||
</div>
|
||||
<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>
|
||||
<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 id="rtmpCallerFields" style="display:none;">
|
||||
|
|
@ -630,7 +630,7 @@
|
|||
const keyIn = document.getElementById('rtmpKey');
|
||||
const update = () => {
|
||||
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);
|
||||
keyIn?.addEventListener('input', update);
|
||||
|
|
@ -651,7 +651,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Projects for recorder destination
|
||||
// ── Projects for recorder destination ─────
|
||||
async function loadProjects() {
|
||||
const r = await getProjects();
|
||||
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>`);
|
||||
}
|
||||
|
||||
// Save recorder
|
||||
// ── Save recorder ─────────────────────────
|
||||
async function handleSaveRecorder() {
|
||||
const name = document.getElementById('recName').value.trim();
|
||||
if (!name) { toast('Enter a recorder name', '', 'warning'); return; }
|
||||
|
|
@ -687,12 +687,12 @@
|
|||
sourceConfig.device = parseInt(document.getElementById('sdiDevice')?.value || '0');
|
||||
} else if (type === 'srt') {
|
||||
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 if (type === 'rtmp') {
|
||||
sourceConfig.mode = mode;
|
||||
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';
|
||||
} else {
|
||||
sourceConfig.url = document.getElementById('rtmpUrl')?.value;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
|
|||
'-ss', timeCode,
|
||||
'-i', inputPath,
|
||||
'-vframes', '1',
|
||||
'-pix_fmt', 'yuvj420p',
|
||||
'-q:v', '2',
|
||||
'-y',
|
||||
outputPath,
|
||||
|
|
@ -48,6 +49,7 @@ export const extractFrameAtTime = async (inputPath, outputPath, timeCode) => {
|
|||
await runFFmpeg(args);
|
||||
};
|
||||
|
||||
// PATCHED for browser-compat pix_fmt
|
||||
export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
|
||||
const {
|
||||
videoCodec = 'libx264',
|
||||
|
|
@ -64,6 +66,7 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
|
|||
'-b:v', videoBitrate,
|
||||
'-c:a', audioCodec,
|
||||
'-b:a', audioBitrate,
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-movflags', '+faststart',
|
||||
'-y',
|
||||
outputPath,
|
||||
|
|
|
|||
Loading…
Reference in a new issue