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:
parent
b6f5b9b407
commit
888ca65045
8 changed files with 390 additions and 7 deletions
|
|
@ -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)').
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(() => '');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue