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 };
|
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
|
// Default: SDI via DeckLink
|
||||||
// device may be an integer index (0-based) or a full device name string.
|
// 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)').
|
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn } from 'child_process';
|
||||||
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import captureManager from '../capture-manager.js';
|
import captureManager from '../capture-manager.js';
|
||||||
|
|
||||||
import dgram from 'dgram';
|
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 /status
|
||||||
* Get current capture 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) {
|
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.' });
|
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);
|
CREATE INDEX idx_sessions_expire ON sessions(expire);
|
||||||
|
|
||||||
-- Recorder source types
|
-- 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
|
-- Recorder instances table
|
||||||
-- NOTE: current_session_id is TEXT (stores a human-readable clip name, not a UUID)
|
-- 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); }
|
} 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
|
// GET /:id/ping – probe the node's api_url/health endpoint directly
|
||||||
router.get('/:id/ping', async (req, res, next) => {
|
router.get('/:id/ping', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,13 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
`GROWING_PATH=/growing`,
|
`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') {
|
if (sourceType === 'srt' || sourceType === 'rtmp') {
|
||||||
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
env.push(`LISTEN=${isListener ? '1' : '0'}`);
|
||||||
if (isListener) {
|
if (isListener) {
|
||||||
|
|
@ -450,6 +457,15 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
|
|
||||||
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
|
const hostBinds = ['/mnt/NVME/MAM/wild-dragon-live:/live'];
|
||||||
if (sourceType === 'sdi') hostBinds.push('/dev/blackmagic:/dev/blackmagic');
|
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');
|
if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing');
|
||||||
|
|
||||||
const containerConfig = {
|
const containerConfig = {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,14 @@ async function handleSidecarStart(body, res) {
|
||||||
|
|
||||||
const binds = [`${LIVE_DIR}:/live`];
|
const binds = [`${LIVE_DIR}:/live`];
|
||||||
if (sourceType === 'sdi') binds.unshift('/dev/blackmagic:/dev/blackmagic');
|
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 = {
|
const spec = {
|
||||||
Image: image,
|
Image: image,
|
||||||
|
|
@ -264,7 +272,7 @@ async function probeGpusViaSmi() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectHardware() {
|
function detectHardware() {
|
||||||
const capabilities = { gpus: [], blackmagic: [] };
|
const capabilities = { gpus: [], blackmagic: [], deltacast: [] };
|
||||||
|
|
||||||
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
|
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
|
||||||
// path, throwing away the nvidia-smi enrichment (model, memory, driver
|
// 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, ...).
|
// to render the correct card layout (Duo 2, Quad 2, Mini Recorder, ...).
|
||||||
if (process.env.BMD_MODEL) capabilities.blackmagic_model = process.env.BMD_MODEL;
|
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;
|
return capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,9 +381,10 @@ async function heartbeat() {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const gpuStr = capabilities.gpus.length ? ` gpu=${capabilities.gpus.length}` : '';
|
const gpuStr = capabilities.gpus.length ? ` gpu=${capabilities.gpus.length}` : '';
|
||||||
const bmdStr = capabilities.blackmagic.length ? ` bmd=${capabilities.blackmagic.length}` : '';
|
const bmdStr = capabilities.blackmagic.length ? ` bmd=${capabilities.blackmagic.length}` : '';
|
||||||
|
const dcStr = capabilities.deltacast.length ? ` dc=${capabilities.deltacast.length}` : '';
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`[hb] ${payload.hostname} ip=${ip_address || '?'} cpu=${cpu_usage}% ` +
|
`[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 {
|
} else {
|
||||||
const txt = await res.text().catch(() => '');
|
const txt = await res.text().catch(() => '');
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,12 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
return n ? (n.id || n.hostname || '') : '';
|
return n ? (n.id || n.hostname || '') : '';
|
||||||
});
|
});
|
||||||
const [sdiDevices, setSdiDevices] = React.useState(null);
|
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 [recTab, setRecTab] = React.useState('video');
|
||||||
const [recCodec, setRecCodec] = React.useState('prores_hq');
|
const [recCodec, setRecCodec] = React.useState('prores_hq');
|
||||||
const [recContainer, setRecContainer] = React.useState('mov');
|
const [recContainer, setRecContainer] = React.useState('mov');
|
||||||
|
|
@ -59,6 +65,13 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
.catch(() => setSdiDevices([]));
|
.catch(() => setSdiDevices([]));
|
||||||
}, [sourceType]);
|
}, [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]);
|
React.useEffect(() => { setProbeResult(null); }, [sourceType, srtUrl, rtmpUrl]);
|
||||||
|
|
||||||
const handleProbe = () => {
|
const handleProbe = () => {
|
||||||
|
|
@ -75,6 +88,7 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
|
if (!name.trim()) { setSubmitErr('Recorder name is required.'); return; }
|
||||||
if (sourceType === 'SDI' && !sdiNodeId) { setSubmitErr('Select a capture node for SDI.'); 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);
|
setSubmitting(true);
|
||||||
setSubmitErr(null);
|
setSubmitErr(null);
|
||||||
|
|
||||||
|
|
@ -91,8 +105,12 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
body.source_config = { url: srtUrl };
|
body.source_config = { url: srtUrl };
|
||||||
} else if (sourceType === 'RTMP') {
|
} else if (sourceType === 'RTMP') {
|
||||||
body.source_config = { url: rtmpUrl };
|
body.source_config = { url: rtmpUrl };
|
||||||
|
} else if (sourceType === 'DELTACAST') {
|
||||||
|
body.source_config = {};
|
||||||
|
body.device_index = dcDeviceIdx;
|
||||||
|
body.node_id = dcNodeId || undefined;
|
||||||
} else {
|
} 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.source_config = {};
|
||||||
body.device_index = sdiDeviceIdx;
|
body.device_index = sdiDeviceIdx;
|
||||||
body.node_id = sdiNodeId || undefined;
|
body.node_id = sdiNodeId || undefined;
|
||||||
|
|
@ -133,9 +151,10 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
<label className="field-label">Source type</label>
|
<label className="field-label">Source type</label>
|
||||||
<div className="source-type-grid">
|
<div className="source-type-grid">
|
||||||
{[
|
{[
|
||||||
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
|
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport — pull caller', icon: 'signal' },
|
||||||
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
|
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
|
||||||
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
|
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
|
||||||
|
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button key={t.id}
|
<button key={t.id}
|
||||||
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
|
className={`source-type-card ${sourceType === t.id ? 'active' : ''}`}
|
||||||
|
|
@ -245,6 +264,77 @@ function NewRecorderModal({ open, onClose }) {
|
||||||
</div>
|
</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">
|
||||||
<div className="modal-section-head">
|
<div className="modal-section-head">
|
||||||
<span>Master recording</span>
|
<span>Master recording</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue