fix: SDI crash, monitors polling, home RAM fields, editor IN DEV splash, timecode, create recorder API: screens-ingest.jsx

This commit is contained in:
Zac Gaetano 2026-05-22 10:55:19 -04:00
parent 48ee66e744
commit 24a1d57165

View file

@ -84,30 +84,45 @@ function Upload({ navigate }) {
}
/* ===== Recorders ===== */
function _normRecorder(r) {
let elapsed = '—';
if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' +
String(s % 60).padStart(2, '0');
}
const cfg = r.source_config || {};
return {
...r,
source: r.source_type || '—',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—',
codec: r.recording_codec || '—',
res: r.recording_resolution || '—',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed,
bitrate: '—',
health: 100,
audio: false,
};
}
function Recorders({ navigate, onNew }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
// Poll every 10s for recorder status changes
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
}, []);
React.useEffect(() => {
const refresh = () => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(r => {
let elapsed = '—';
if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s/3600)).padStart(2,'0')+':'+String(Math.floor((s%3600)/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');
}
const cfg = r.source_config || {};
return { ...r, source: r.source_type||'—', url: cfg.url||cfg.address||r.source_type||'—', codec: r.recording_codec||'—', res: r.recording_resolution||'—', node: r.node_id||'primary', elapsed, bitrate:'—', health:100, audio:false };
});
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
};
const i = setInterval(refresh, 10000);
return () => clearInterval(i);
const id = setInterval(refresh, 10000);
return () => clearInterval(id);
}, []);
const liveCount = recorders.filter(r => r.status === 'recording').length;
@ -125,6 +140,7 @@ function Recorders({ navigate, onNew }) {
<span>{liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}</span>
</div>
)}
<button className="btn ghost sm" onClick={refresh}><Icon name="refresh" />Refresh</button>
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
</div>
<div className="page-body">
@ -135,7 +151,7 @@ function Recorders({ navigate, onNew }) {
</div>
) : (
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={() => { window.ZAMPP_API.fetch('/recorders').then(raw => setRecorders((raw||[]).map(x => { const cfg=x.source_config||{}; return {...x,source:x.source_type,url:cfg.url||cfg.address||x.source_type,codec:x.recording_codec,res:x.recording_resolution,node:x.node_id,elapsed:'—',bitrate:'—',health:100,audio:false}; }))).catch(()=>{}); }} />)}
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
)}
</div>
@ -143,13 +159,23 @@ function Recorders({ navigate, onNew }) {
);
}
function RecorderRow({ recorder, onRefresh }) {
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
const [err, setErr] = React.useState(null);
const isRec = recorder.status === 'recording';
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
const toggle = () => {
if (pending) return;
const action = isRec ? 'stop' : 'start';
setPending(true);
setErr(null);
setRecorder(r => ({ ...r, status: isRec ? 'idle' : 'recording' }));
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
.then(onRefresh).catch(() => {});
.then(() => { setPending(false); onRefresh(); })
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
return (
@ -172,6 +198,7 @@ function RecorderRow({ recorder, onRefresh }) {
<span>{recorder.codec}</span><span>·</span>
<span>{recorder.res}</span>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
</div>
<div className="recorder-stats">
<div className="recorder-stat">
@ -185,8 +212,12 @@ function RecorderRow({ recorder, onRefresh }) {
</div>
<div className="recorder-actions">
{isRec
? <button className="btn danger sm" onClick={toggle}><span className="rec-dot" />Stop</button>
: <button className="btn subtle sm" onClick={toggle}><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</button>}
? <button className="btn danger sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" />Stop</>}
</button>
: <button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>}
<button className="icon-btn"><Icon name="more" /></button>
</div>
</div>
@ -214,11 +245,13 @@ function Capture({ navigate }) {
const [devices, setDevices] = React.useState([]);
const [activeIdx, setActiveIdx] = React.useState(0);
React.useEffect(() => {
const loadDevices = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(devs || []))
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([]));
}, []);
};
React.useEffect(() => { loadDevices(); }, []);
if (devices.length === 0) {
return (
@ -227,8 +260,7 @@ function Capture({ navigate }) {
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/cluster/devices/blackmagic').then(d => setDevices(d||[])).catch(()=>{})}
><Icon name="refresh" />Refresh</button>
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
@ -247,7 +279,7 @@ function Capture({ navigate }) {
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest {devices.length} device{devices.length > 1 ? 's' : ''} in cluster</span>
<div className="spacer" />
<button className="btn ghost sm" onClick={() => window.ZAMPP_API.fetch('/cluster/devices/blackmagic').then(d => setDevices(d||[])).catch(()=>{})}><Icon name="refresh" />Refresh</button>
<button className="btn ghost sm" onClick={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
@ -274,14 +306,25 @@ function Capture({ navigate }) {
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const { RECORDERS } = window.ZAMPP_DATA;
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
const [grid, setGrid] = React.useState(4);
const videoFeeds = RECORDERS.filter(r => !r.audio);
const audioFeeds = [
{ id: '__audio1', name: 'FOH Mix', kind: 'audio' },
{ id: '__audio2', name: 'PGM Bus', kind: 'audio' },
];
React.useEffect(() => {
const refresh = () => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
};
const id = setInterval(refresh, 5000);
return () => clearInterval(id);
}, []);
const videoFeeds = recorders.filter(r => !r.audio);
const audioFeeds = recorders.filter(r => r.audio).map(r => ({ ...r, kind: 'audio' }));
const allFeeds = [
...videoFeeds.map(r => ({ ...r, kind: 'video' })),
...audioFeeds,
@ -301,18 +344,30 @@ function Monitors({ navigate }) {
</div>
</div>
<div className="page-body">
<div className="monitors-grid" style={{ gridTemplateColumns: 'repeat(' + grid + ', 1fr)' }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
{feeds.length === 0 && (
<div style={{ gridColumn: '1/-1', padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
)}
</div>
{feeds.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No active feeds. Start a recorder to see live video here.</div>
) : (
<div className="monitors-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(' + grid + ', 1fr)', gap: 8 }}>
{feeds.map((f, i) => <MonitorTile key={f.id} feed={f} seed={i + 1} />)}
</div>
)}
</div>
</div>
);
}
function MonitorTile({ feed, seed }) {
const [levels, setLevels] = React.useState([0.65, 0.78]);
const isLive = feed.status === 'recording';
React.useEffect(() => {
if (!isLive) return;
const id = setInterval(() => {
setLevels([0.3 + Math.random() * 0.55, 0.3 + Math.random() * 0.55]);
}, 180);
return () => clearInterval(id);
}, [isLive]);
if (feed.kind === 'audio') {
return (
<div className="monitor-tile audio">
@ -320,8 +375,8 @@ function MonitorTile({ feed, seed }) {
<Waveform seed={seed * 7} color="var(--accent)" />
</div>
<div style={{ display: 'flex', gap: 4, padding: '0 16px 16px', justifyContent: 'center' }}>
<AudioMeter level={0.65} vertical />
<AudioMeter level={0.78} vertical />
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
<div className="monitor-tile-label">
<span className="badge live">LIVE</span>
@ -330,14 +385,25 @@ function MonitorTile({ feed, seed }) {
</div>
);
}
return (
<div className="monitor-tile">
<FauxFrame />
{isLive && (
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />
)}
<div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', gap: 6 }}>
{feed.status === 'recording' && <span className="badge live">REC</span>}
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
{feed.status === 'error' && <span className="badge danger">ERR</span>}
{isLive && <span className="badge live">REC</span>}
{feed.status === 'stopped' && <span className="badge neutral">IDLE</span>}
{feed.status === 'idle' && <span className="badge neutral">IDLE</span>}
{feed.status === 'error' && <span className="badge danger">ERR</span>}
</div>
{isLive && (
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
<AudioMeter level={levels[0]} vertical />
<AudioMeter level={levels[1]} vertical />
</div>
)}
<div className="monitor-tile-label">
<span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== '—' && <span className="time mono">{feed.elapsed}</span>}