From 888ca65045a30c0ab4df7fcd7ded85d5ac3c8fc1 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 28 May 2026 23:12:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(capture):=20Deltacast=20SDI=20framework=20?= =?UTF-8?q?=E2=80=94=20test-card=20capture,=20cluster=20detection,=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## capture service - capture-manager.js: add 'deltacast' source_type to _buildInputArgs. Uses 'deltacast://' with ffmpeg deltacast demuxer when /dev/deltacast 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. --- services/capture/src/capture-manager.js | 37 ++++++ services/capture/src/routes/capture.js | 74 +++++++++++ .../migrations/024-deltacast-source-type.sql | 13 ++ services/mam-api/src/db/schema.sql | 2 +- services/mam-api/src/routes/cluster.js | 118 ++++++++++++++++++ services/mam-api/src/routes/recorders.js | 16 +++ services/node-agent/index.js | 39 +++++- services/web-ui/public/modal-new-recorder.jsx | 98 ++++++++++++++- 8 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 services/mam-api/src/db/migrations/024-deltacast-source-type.sql diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 98180c1..1dd177e 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -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://'. + // When the physical device is absent (/dev/deltacast 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)'). diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index f26f108..158c224 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -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 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.' }); } diff --git a/services/mam-api/src/db/migrations/024-deltacast-source-type.sql b/services/mam-api/src/db/migrations/024-deltacast-source-type.sql new file mode 100644 index 0000000..f72ee62 --- /dev/null +++ b/services/mam-api/src/db/migrations/024-deltacast-source-type.sql @@ -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 $$; diff --git a/services/mam-api/src/db/schema.sql b/services/mam-api/src/db/schema.sql index a20a75e..dd139c9 100644 --- a/services/mam-api/src/db/schema.sql +++ b/services/mam-api/src/db/schema.sql @@ -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) diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 3a0a417..327055f 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -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 { diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 9c8e316..2511133 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -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 = { diff --git a/services/node-agent/index.js b/services/node-agent/index.js index e8bf31d..5d132eb 100644 --- a/services/node-agent/index.js +++ b/services/node-agent/index.js @@ -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(() => ''); diff --git a/services/web-ui/public/modal-new-recorder.jsx b/services/web-ui/public/modal-new-recorder.jsx index 6168d7d..ea87bba 100644 --- a/services/web-ui/public/modal-new-recorder.jsx +++ b/services/web-ui/public/modal-new-recorder.jsx @@ -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 }) {
{[ - { 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 => ( + ))} +
+ )); + })()} + + )} + {dcDevices !== null && dcDevices.length === 0 && ( +
+
+ No Deltacast devices detected. Configure manually (test-card mode): +
+
+
+ + +
+
+ + +
+
+
+ )} + + )} +
Master recording