Compare commits
1 commit
main
...
fix/srt-rt
| Author | SHA1 | Date | |
|---|---|---|---|
| 79369c378a |
5 changed files with 166 additions and 47 deletions
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue