feat(capture): Deltacast SDI framework — test-card capture, cluster detection, UI

## capture service
- capture-manager.js: add 'deltacast' source_type to _buildInputArgs.
  Uses 'deltacast://<index>' with ffmpeg deltacast demuxer when
  /dev/deltacast<N> exists; falls back to lavfi testsrc2 + sine test card
  (matching deltacast-sdi-recorder standalone app) when hardware absent.
- routes/capture.js: add GET /devices/deltacast endpoint (enumerates
  /dev/deltacast* + DELTACAST_PORT_COUNT env fallback). Extend /probe to
  handle source_type=deltacast.

## node-agent
- detectHardware(): add 'deltacast' array to capabilities payload.
  Enumerates /dev/deltacast* nodes; falls back to DELTACAST_PORT_COUNT env.
  Adds DELTACAST_MODEL env support. Logs dc= count in heartbeat line.
- sidecar /start: bind /dev/deltacast* device nodes into capture containers
  when sourceType='deltacast'.

## mam-api
- cluster.js: add GET /cluster/devices/deltacast and
  GET /cluster/devices/deltacast/signal endpoints — same shape as
  blackmagic equivalents for UI parity.
- recorders.js /start: pass DELTACAST_PORT_COUNT env to capture container;
  bind /dev/deltacast* device nodes on local spawn.
- migration 024: ALTER TYPE source_type ADD VALUE 'deltacast' (idempotent).
- schema.sql: add 'deltacast' to source_type ENUM for fresh installs.

## web-ui
- modal-new-recorder.jsx: add 'Deltacast' source type card; fetch
  /cluster/devices/deltacast on selection; port picker with TEST CARD
  badge when hardware absent; falls through to manual index entry if
  no devices detected.
This commit is contained in:
Zac Gaetano 2026-05-28 23:12:40 +00:00
parent b6f5b9b407
commit 888ca65045
8 changed files with 390 additions and 7 deletions

View file

@ -128,6 +128,43 @@ class CaptureManager {
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
// When the physical device is absent (/dev/deltacast<N> missing), fall back
// to a lavfi test card so development and integration testing work without hardware.
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10)
: 0;
const { existsSync } = await import('node:fs');
const deviceNode = `/dev/deltacast${idx}`;
if (existsSync(deviceNode)) {
console.log(`[capture] Deltacast index ${idx}${deviceNode} (hardware)`);
return {
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
isNetwork: false,
};
} else {
// No hardware — lavfi test card with port label + timecode burn-in.
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
// recorded files look right in the MAM library during dev.
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
const testSrc = [
`testsrc2=size=1920x1080:rate=30`,
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
].join(',');
return {
inputArgs: [
'-f', 'lavfi', '-i', testSrc,
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
'-map', '0:v:0', '-map', '1:a:0',
],
isNetwork: false,
};
}
}
// Default: SDI via DeckLink
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').

View file

@ -1,5 +1,6 @@
import express from 'express';
import { execSync, spawn } from 'child_process';
import { existsSync, readdirSync } from 'node:fs';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
@ -118,6 +119,57 @@ router.get('/devices', (req, res) => {
}
});
/**
* GET /devices/deltacast
* List available Deltacast ports.
* Reads /dev/deltacast<N> nodes; falls back to env DELTACAST_PORT_COUNT
* so nodes without hardware still report their configured port count
* (test-card mode).
*/
router.get('/devices/deltacast', (req, res) => {
try {
const devices = [];
// First: enumerate actual /dev/deltacast* device nodes.
try {
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
devEntries.sort();
for (const entry of devEntries) {
const m = entry.match(/^deltacast(\d+)$/);
if (m) {
devices.push({
index: parseInt(m[1], 10),
name: `Deltacast Port ${m[1]}`,
device: `/dev/${entry}`,
present: true,
});
}
}
} catch (_) { /* /dev always exists; ignore */ }
// Second: if DELTACAST_PORT_COUNT env is set and larger than what we found,
// fill in the remaining slots as test-card entries (no physical device).
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const found = new Set(devices.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!found.has(i)) {
devices.push({
index: i,
name: `Deltacast Port ${i} (test card)`,
device: `/dev/deltacast${i}`,
present: false,
});
}
}
devices.sort((a, b) => a.index - b.index);
res.json({ devices });
} catch (error) {
console.error('Error listing Deltacast devices:', error);
res.status(500).json({ error: 'Failed to list Deltacast devices' });
}
});
/**
* GET /status
* Get current capture status
@ -150,6 +202,28 @@ router.post('/probe', async (req, res) => {
}
}
if (source_type === 'deltacast') {
// Enumerate /dev/deltacast* nodes; report present/absent per index.
try {
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
const found = devEntries.map(n => {
const m = n.match(/^deltacast(\d+)$/);
return { index: parseInt(m[1], 10), device: `/dev/${n}`, present: true };
});
const foundIdx = new Set(found.map(d => d.index));
for (let i = 0; i < envCount; i++) {
if (!foundIdx.has(i)) {
found.push({ index: i, device: `/dev/deltacast${i}`, present: false });
}
}
found.sort((a, b) => a.index - b.index);
return res.json({ ok: true, source_type, devices: found });
} catch (err) {
return res.json({ ok: false, source_type, error: err.message });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}

View file

@ -0,0 +1,13 @@
-- Migration 024: add 'deltacast' to the source_type enum
-- Allows recorders to be configured for Deltacast VideoMaster SDI cards.
-- ALTER TYPE ... ADD VALUE is not transactional in PG < 12 but is safe in PG 12+.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumtypid = 'source_type'::regtype
AND enumlabel = 'deltacast'
) THEN
ALTER TYPE source_type ADD VALUE 'deltacast';
END IF;
END $$;

View file

@ -139,7 +139,7 @@ CREATE INDEX idx_bins_project_id ON bins(project_id);
CREATE INDEX idx_sessions_expire ON sessions(expire);
-- Recorder source types
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp');
CREATE TYPE source_type AS ENUM ('sdi', 'srt', 'rtmp', 'deltacast');
-- Recorder instances table
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)

View file

@ -302,6 +302,124 @@ router.get('/devices/blackmagic', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /devices/deltacast flatten every node's Deltacast cards for the
// recorder picker. Mirrors /devices/blackmagic shape so the UI can treat
// both card types uniformly.
router.get('/devices/deltacast', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120;
const dc = (row.capabilities && row.capabilities.deltacast) || [];
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
// Also synthesise entries from DELTACAST_PORT_COUNT if no entries reported yet —
// useful for nodes that haven't sent a heartbeat since the agent was updated.
dc.forEach((d, idx) => {
out.push({
node_id: row.id,
hostname: row.hostname,
ip_address: row.ip_address,
role: row.role,
online,
model: model || 'Deltacast',
index: d.index !== undefined ? d.index : idx,
device: d.device,
present: d.present !== false,
port_count: dc.length,
});
});
}
res.json(out);
} catch (err) { next(err); }
});
// GET /devices/deltacast/signal live signal state for Deltacast ports.
// Same pattern as /devices/blackmagic/signal.
router.get('/devices/deltacast/signal', async (req, res, next) => {
try {
const [nodesRes, recordersRes] = await Promise.all([
pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
),
pool.query(
`SELECT id, node_id, device_index, status, source_type, container_id
FROM recorders WHERE source_type = 'deltacast'`
),
]);
const recByNodePort = {};
for (const rec of recordersRes.rows) {
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
}
const results = [];
const fetchPromises = [];
for (const node of nodesRes.rows) {
const online = Number(node.stale_seconds) < 120;
const dc = (node.capabilities && node.capabilities.deltacast) || [];
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
for (const port of dc) {
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
const rec = recByNodePort[`${node.id}:${idx}`];
const base = {
node_id: node.id,
hostname: node.hostname,
ip_address: node.ip_address,
online,
model,
index: idx,
device: port.device,
present: port.present !== false,
recorder_id: rec ? rec.id : null,
recorder_status: rec ? rec.status : null,
signal: 'no-recorder',
framesReceived: null,
currentFps: null,
};
if (!rec) { results.push(base); continue; }
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
// Active recording — query capture container for real signal.
const fetchIdx = results.length;
results.push(base);
fetchPromises.push((async () => {
try {
const url = node.api_url
? `${node.api_url}/sidecar/${rec.container_id}/status`
: `http://recorder-${rec.id}:3001/capture/status`;
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
if (r.ok) {
const live = await r.json();
if (live && live.signal) {
results[fetchIdx].signal = live.signal;
results[fetchIdx].framesReceived = live.framesReceived ?? null;
results[fetchIdx].currentFps = live.currentFps ?? null;
}
}
} catch (_) {
results[fetchIdx].signal = 'connecting';
}
})());
}
}
await Promise.all(fetchPromises);
res.json(results);
} catch (err) { next(err); }
});
// GET /:id/ping probe the node's api_url/health endpoint directly
router.get('/:id/ping', async (req, res, next) => {
try {

View file

@ -405,6 +405,13 @@ router.post('/:id/start', async (req, res, next) => {
`GROWING_PATH=/growing`,
];
// Deltacast: pass port count so the capture container can enumerate
// test-card slots even without physical /dev/deltacast* nodes.
if (sourceType === 'deltacast') {
const dcCount = process.env.DELTACAST_PORT_COUNT || sourceConfig.port_count || '';
if (dcCount) env.push(`DELTACAST_PORT_COUNT=${dcCount}`);
}
if (sourceType === 'srt' || sourceType === 'rtmp') {
env.push(`LISTEN=${isListener ? '1' : '0'}`);
if (isListener) {
@ -450,6 +457,15 @@ router.post('/:id/start', async (req, res, next) => {
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
// Bind each /dev/deltacast* device node the host has into the container.
// The capture service falls back to test-card if none are present.
try {
const { readdirSync } = await import('node:fs');
const dcEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) hostBinds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* no /dev/deltacast* nodes on this host */ }
}
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
const containerConfig = {

View file

@ -91,6 +91,14 @@ async function handleSidecarStart(body, res) {
const binds = [`${LIVE_DIR}:/live`];
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
if (sourceType === 'deltacast') {
// Bind each /dev/deltacast* node that exists on the host into the container.
// If none exist the capture container falls back to test-card (lavfi) mode.
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
for (const d of dcEntries) binds.push(`/dev/${d}:/dev/${d}`);
} catch (_) { /* /dev always exists */ }
}
const spec = {
Image: image,
@ -264,7 +272,7 @@ async function probeGpusViaSmi() {
}
function detectHardware() {
const capabilities = { gpus: [], blackmagic: [] };
const capabilities = { gpus: [], blackmagic: [], deltacast: [] };
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
// path, throwing away the nvidia-smi enrichment (model, memory, driver
@ -311,6 +319,32 @@ function detectHardware() {
// to render the correct card layout (Duo 2, Quad 2, Mini Recorder, ...).
if (process.env.BMD_MODEL) capabilities.blackmagic_model = process.env.BMD_MODEL;
// Deltacast SDI cards — enumerate /dev/deltacast* device nodes.
// DELTACAST_PORT_COUNT env overrides when devices aren't mapped (test/dev mode).
const dcOverride = parseInt(process.env.DELTACAST_PORT_COUNT || '-1', 10);
try {
const dcEntries = fs.readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
if (dcEntries.length > 0) {
capabilities.deltacast = dcEntries.map((d, i) => ({
device: `/dev/${d}`,
index: i,
present: true,
}));
} else if (dcOverride >= 0) {
// No device nodes but count is configured — test-card mode.
for (let i = 0; i < dcOverride; i++) {
capabilities.deltacast.push({ device: `/dev/deltacast${i}`, index: i, present: false });
}
}
} catch (_) {
if (dcOverride >= 0) {
for (let i = 0; i < dcOverride; i++) {
capabilities.deltacast.push({ device: `/dev/deltacast${i}`, index: i, present: false });
}
}
}
if (process.env.DELTACAST_MODEL) capabilities.deltacast_model = process.env.DELTACAST_MODEL;
return capabilities;
}
@ -347,9 +381,10 @@ async function heartbeat() {
if (res.ok) {
const gpuStr = capabilities.gpus.length ? ` gpu=${capabilities.gpus.length}` : '';
const bmdStr = capabilities.blackmagic.length ? ` bmd=${capabilities.blackmagic.length}` : '';
const dcStr = capabilities.deltacast.length ? ` dc=${capabilities.deltacast.length}` : '';
process.stdout.write(
`[hb] ${payload.hostname} ip=${ip_address || '?'} cpu=${cpu_usage}% ` +
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}\n`
`mem=${payload.mem_used_mb}/${payload.mem_total_mb}MB${gpuStr}${bmdStr}${dcStr}\n`
);
} else {
const txt = await res.text().catch(() => '');

View file

@ -42,6 +42,12 @@ function NewRecorderModal({ open, onClose }) {
return n ? (n.id || n.hostname || '') : '';
});
const [sdiDevices, setSdiDevices] = React.useState(null);
const [dcDeviceIdx, setDcDeviceIdx] = React.useState(0);
const [dcNodeId, setDcNodeId] = React.useState(() => {
const n = NODES[0];
return n ? (n.id || n.hostname || '') : '';
});
const [dcDevices, setDcDevices] = React.useState(null);
const [recTab, setRecTab] = React.useState('video');
const [recCodec, setRecCodec] = React.useState('prores_hq');
const [recContainer, setRecContainer] = React.useState('mov');
@ -59,6 +65,13 @@ function NewRecorderModal({ open, onClose }) {
.catch(() => setSdiDevices([]));
}, [sourceType]);
React.useEffect(() => {
if (sourceType !== 'DELTACAST' || dcDevices !== null) return;
window.ZAMPP_API.fetch('/cluster/devices/deltacast')
.then(d => setDcDevices(Array.isArray(d) ? d : []))
.catch(() => setDcDevices([]));
}, [sourceType]);
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
const handleProbe = () => {
@ -75,6 +88,7 @@ function NewRecorderModal({ open, onClose }) {
const handleCreate = () => {
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); return; }
if (sourceType === 'DELTACAST' && !dcNodeId) { setSubmitErr('Select a capture node for Deltacast.'); return; }
setSubmitting(true);
setSubmitErr(null);
@ -91,8 +105,12 @@ function NewRecorderModal({ open, onClose }) {
body.source_config = { url: srtUrl };
} else if (sourceType === 'RTMP') {
body.source_config = { url: rtmpUrl };
} else if (sourceType === 'DELTACAST') {
body.source_config = {};
body.device_index = dcDeviceIdx;
body.node_id = dcNodeId || undefined;
} else {
// SDI: device_index and node_id are top-level fields
// SDI (DeckLink): device_index and node_id are top-level fields
body.source_config = {};
body.device_index = sdiDeviceIdx;
body.node_id = sdiNodeId || undefined;
@ -133,9 +151,10 @@ function NewRecorderModal({ open, onClose }) {
<label className="field-label">Source type</label>
<div className="source-type-grid">
{[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
].map(t => (
<button key={t.id}
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
@ -245,6 +264,77 @@ function NewRecorderModal({ open, onClose }) {
</div>
)}
{sourceType === 'DELTACAST' && (
<div className="field">
<label className="field-label">Capture device</label>
{dcDevices === null && (
<div style={{ padding: '10px 0', color: 'var(--text-3)', fontSize: 12 }}>Detecting Deltacast devices</div>
)}
{dcDevices !== null && dcDevices.length > 0 && (
<div className="sdi-port-mini">
{(() => {
// Group by node
const byNode = {};
dcDevices.forEach(dev => {
const key = dev.node_id || dev.hostname || 'unknown';
if (!byNode[key]) byNode[key] = { ...dev, ports: [] };
byNode[key].ports.push(dev);
});
return Object.values(byNode).map((node, ni) => (
<div key={ni} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '4px 0 8px' }}>
{(node.model || 'Deltacast').toUpperCase()} · {node.hostname}
</div>
{node.ports.map(port => (
<button key={port.index}
className={`sdi-mini-port ${dcDeviceIdx === port.index && dcNodeId === (port.node_id || port.hostname || '') ? 'active' : ''}`}
onClick={() => { setDcDeviceIdx(port.index); setDcNodeId(port.node_id || port.hostname || ''); }}>
<span className="sdi-mini-radio"><span className="sdi-mini-radio-dot" /></span>
<span style={{ fontSize: 12, fontWeight: 600 }}>
Port {port.index + 1}
{!port.present && <span style={{ fontSize: 10, color: 'var(--accent)', marginLeft: 6 }}>TEST CARD</span>}
</span>
<span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 6 }}>index {port.index}</span>
</button>
))}
</div>
));
})()}
</div>
)}
{dcDevices !== null && dcDevices.length === 0 && (
<div style={{ padding: '8px 0' }}>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
No Deltacast devices detected. Configure manually (test-card mode):
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div className="field">
<label className="field-label">Capture node</label>
<select className="field-input" value={dcNodeId}
onChange={e => setDcNodeId(e.target.value)} style={{ appearance: 'auto' }}>
{NODES.length === 0
? <option value="">No cluster nodes</option>
: NODES.map(n => {
const id = n.id || n.hostname || n.name || '';
const label = n.hostname || n.name || id;
return <option key={id} value={id}>{label}</option>;
})}
</select>
</div>
<div className="field">
<label className="field-label">Port index</label>
<select className="field-input" value={dcDeviceIdx}
onChange={e => setDcDeviceIdx(Number(e.target.value))} style={{ appearance: 'auto' }}>
{[0, 1, 2, 3, 4, 5, 6, 7].map(i =>
<option key={i} value={i}>Port {i + 1} (index {i})</option>)}
</select>
</div>
</div>
</div>
)}
</div>
)}
<div className="modal-section">
<div className="modal-section-head">
<span>Master recording</span>