diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx
index d213acc..e62afea 100644
--- a/services/web-ui/public/screens-ingest.jsx
+++ b/services/web-ui/public/screens-ingest.jsx
@@ -1,29 +1,28 @@
-// screens-ingest.jsx — Upload, Recorders, Capture (SDI), Monitors
+// screens-ingest.jsx — Upload, Recorders, Capture, Monitors
-const { RECORDERS, NODES, SDI_PORTS_zampp2, PROJECTS } = window.ZAMPP_DATA;
-
-/* ========== Upload ========== */
+/* ===== Upload ===== */
function Upload({ navigate }) {
- const [files, setFiles] = React.useState([
- { id: 1, name: "Drone_Aerial_Lap_4.mov", size: "12.4 GB", progress: 68, status: "uploading" },
- { id: 2, name: "Interview_Director.mxf", size: "2.1 GB", progress: 100, status: "done" },
- { id: 3, name: "Sponsor_Logo_v4.mov", size: "120 MB", progress: 100, status: "done" },
- { id: 4, name: "Pit_Cam_3.mp4", size: "4.8 GB", progress: 24, status: "uploading" },
- { id: 5, name: "Backstage_Audio.wav", size: "920 MB", progress: 0, status: "queued" },
- ]);
+ const [files, setFiles] = React.useState([]);
+ const [project, setProject] = React.useState('');
React.useEffect(() => {
- const i = setInterval(() => {
- setFiles(fs => fs.map(f => {
- if (f.status !== "uploading") return f;
- const next = f.progress + Math.random() * 4;
- if (next >= 100) return { ...f, progress: 100, status: "done" };
- return { ...f, progress: next };
- }));
- }, 600);
- return () => clearInterval(i);
+ const { PROJECTS } = window.ZAMPP_DATA;
+ if (PROJECTS.length > 0 && !project) setProject(PROJECTS[0].id);
}, []);
+ const handleDrop = (e) => {
+ e.preventDefault();
+ const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
+ const newFiles = dropped.map((f, i) => ({
+ id: Date.now() + i, name: f.name,
+ size: window.ZAMPP_API.fmtSize(f.size),
+ file: f, progress: 0, status: 'queued',
+ }));
+ setFiles(prev => [...prev, ...newFiles]);
+ };
+
+ const { PROJECTS } = window.ZAMPP_DATA;
+
return (
@@ -31,108 +30,139 @@ function Upload({ navigate }) {
Drop video, audio, or stills — we proxy and index automatically.
-
+
-
-
-
Master files
+
+ {PROJECTS.find(p => p.id === project)?.name || (PROJECTS.length ? PROJECTS[0].name : 'No projects')}
+
+
-
-
+
e.preventDefault()}
+ onClick={() => { const i = document.createElement('input'); i.type='file'; i.multiple=true; i.onchange=handleDrop; i.click(); }}>
+
Drop files here or click to browse
-
Video, audio, and image files — up to 5 GB each
+
Video, audio, and image files
- {["MOV", "MP4", "MXF", "ProRes", "DNxHR", "WAV", "AIFF"].map(f => {f})}
+ {['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f => {f})}
-
-
- Queue
- {files.length}
-
-
-
-
-
- {files.map(f => (
-
-
-
-
- {f.name}
- {f.size}
-
-
-
+ {files.length > 0 && (
+
+
+ Queue {files.length}
+
+
+
+
+ {files.map(f => (
+
+
+
+
+ {f.name}
+ {f.size}
+
+
+
+ {f.status === 'done' ? '✓ done' : f.status === 'queued' ? 'queued' : Math.round(f.progress) + '%'}
+
-
- {f.status === "done" ? "✓ done" : f.status === "queued" ? "queued" : `${Math.round(f.progress)}%`}
-
-
- ))}
+ ))}
+
-
+ )}
);
}
-/* ========== Recorders (live ingest dashboard) ========== */
+/* ===== Recorders ===== */
function Recorders({ navigate, onNew }) {
+ const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
+
+ // Poll every 10s for recorder status changes
+ 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 liveCount = recorders.filter(r => r.status === 'recording').length;
+ const errCount = recorders.filter(r => r.status === 'error').length;
+
return (
Recorders
Live ingest from SRT, RTMP, and SDI sources
-
-
- 4 recording · 1 armed · 1 error
-
+ {(liveCount > 0 || errCount > 0) && (
+
+
+ {liveCount > 0 ? liveCount + ' recording' : ''}{errCount > 0 ? (liveCount > 0 ? ' · ' : '') + errCount + ' error' : ''}
+
+ )}
-
- {RECORDERS.map(r => )}
-
+ {recorders.length === 0 ? (
+
+ No recorders configured.
+
+
+ ) : (
+
+ {recorders.map(r => { 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(()=>{}); }} />)}
+
+ )}
);
}
-function RecorderRow({ recorder }) {
- const isRec = recorder.status === "recording";
+function RecorderRow({ recorder, onRefresh }) {
+ const isRec = recorder.status === 'recording';
+
+ const toggle = () => {
+ const action = isRec ? 'stop' : 'start';
+ window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/' + action, { method: 'POST' })
+ .then(onRefresh).catch(() => {});
+ };
+
return (
-
+
- {recorder.audio ? (
-
- ) : isRec ? (
-
- ) : (
-
-
-
- )}
+ {isRec
+ ?
+ :
}
-
+
{recorder.name}
-
+
{recorder.status.toUpperCase()}
{recorder.source}
@@ -140,8 +170,7 @@ function RecorderRow({ recorder }) {
{recorder.url}
{recorder.codec}·
- {recorder.res}·
- node: {recorder.node}
+ {recorder.res}
@@ -150,211 +179,133 @@ function RecorderRow({ recorder }) {
{recorder.elapsed}
-
Bitrate
-
{recorder.bitrate}
-
-
-
Health
-
-
-
+
Status
+
- {isRec ? (
-
- ) : recorder.status === "error" ? (
-
- ) : (
-
- )}
+ {isRec
+ ?
+ : }
);
}
+function badgeForStatus(s) {
+ return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
+}
+
function HealthBar({ value }) {
- const color = value > 80 ? "var(--success)" : value > 40 ? "var(--warning)" : "var(--danger)";
+ const color = value > 80 ? 'var(--success)' : value > 40 ? 'var(--warning)' : 'var(--danger)';
return (
-
-
-
+
);
}
-function badgeForStatus(s) {
- return { recording: "live", armed: "accent", idle: "neutral", error: "danger", offline: "neutral" }[s] || "neutral";
-}
-
-/* ========== Capture (rich SDI port picker) ========== */
+/* ===== Capture ===== */
function Capture({ navigate }) {
- const [activePort, setActivePort] = React.useState(1);
- const ports = SDI_PORTS_zampp2;
+ const [devices, setDevices] = React.useState([]);
+ const [activeIdx, setActiveIdx] = React.useState(0);
+
+ React.useEffect(() => {
+ window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
+ .then(devs => setDevices(devs || []))
+ .catch(() => setDevices([]));
+ }, []);
+
+ if (devices.length === 0) {
+ return (
+
+
+
Capture
+
DeckLink SDI ingest
+
+
+
+
+
+ No DeckLink devices found in cluster.
+
+
+
+ );
+ }
+
+ const active = devices[activeIdx] || devices[0];
return (
Capture
-
DeckLink SDI ingest — multi-port routing across cluster nodes
+
DeckLink SDI ingest — {devices.length} device{devices.length > 1 ? 's' : ''} in cluster
-
-
+
-
- );
-}
-
-function DeckLinkVisual({ ports, activePort, onSelect }) {
- return (
-
-
-
-
DeckLink Duo 2
-
zampp2 · 172.18.91.217
-
-
ONLINE
-
-
-
-
- {ports.map(p => (
-
-
- Ports: 4
- Active: {ports.filter(p => p.active).length}
- Recording: {ports.filter(p => p.recording).length}
-
-
- );
-}
-
-function CaptureDetail({ port }) {
- if (!port.active) {
- return (
-
-
-
No signal on SDI {port.idx}
-
Connect a source, then click Refresh.
-
- );
- }
- return (
-
-
-
-
- {port.recording && (
-
-
REC
+
+
+
+
+
{active.model || active.device || 'DeckLink'}
+
{active.hostname} · {active.ip_address}
+
- )}
-
-
-
+
Connect a source and click Refresh to see port status.
-
- {port.signal}
-
-
-
-
-
-
-
-
-
-
-
- {!port.recording ? (
- Arm + Record
- ) : (
- Stop
- )}
- Take still
- Route to recorder
);
}
-function CaptureStat({ label, value }) {
- return (
-
- );
-}
-
-/* ========== Monitors (multi-cam grid) ========== */
+/* ===== Monitors ===== */
function Monitors({ navigate }) {
+ const { RECORDERS } = window.ZAMPP_DATA;
const [grid, setGrid] = React.useState(4);
- const allFeeds = [
- ...RECORDERS.filter(r => !r.audio).map(r => ({ ...r, kind: "video" })),
- { id: "audio1", name: "FOH Mix", kind: "audio" },
- { id: "audio2", name: "Stage Mics", kind: "audio" },
- { id: "audio3", name: "PGM Bus", kind: "audio" },
+
+ const videoFeeds = RECORDERS.filter(r => !r.audio);
+ const audioFeeds = [
+ { id: '__audio1', name: 'FOH Mix', kind: 'audio' },
+ { id: '__audio2', name: 'PGM Bus', kind: 'audio' },
];
- const feeds = allFeeds.slice(0, grid * grid - (grid === 2 ? 1 : 0));
- const gridSize = grid;
+ const allFeeds = [
+ ...videoFeeds.map(r => ({ ...r, kind: 'video' })),
+ ...audioFeeds,
+ ];
+ const feeds = allFeeds.slice(0, grid * grid);
return (
Monitors
-
Multi-cam live monitoring across all active feeds
+
Multi-cam live monitoring
{[2, 3, 4].map(n => (
- setGrid(n)}>{n}×{n}
+ setGrid(n)}>{n}×{n}
))}
-
Layouts
-
- {feeds.map((f, i) => (
-
- ))}
+
+ {feeds.map((f, i) =>
)}
+ {feeds.length === 0 && (
+
No active feeds. Start a recorder to see live video here.
+ )}
@@ -362,13 +313,13 @@ function Monitors({ navigate }) {
}
function MonitorTile({ feed, seed }) {
- if (feed.kind === "audio") {
+ if (feed.kind === 'audio') {
return (
-
+
-
+
@@ -381,17 +332,15 @@ function MonitorTile({ feed, seed }) {
}
return (
-
-
-
- {feed.status === "recording" &&
REC}
- {feed.status === "armed" &&
ARMED}
- {feed.status === "error" &&
ERR}
- {feed.status === "idle" &&
IDLE}
+
+
+ {feed.status === 'recording' && REC}
+ {feed.status === 'stopped' && IDLE}
+ {feed.status === 'error' && ERR}
{feed.name}
- {feed.elapsed && feed.elapsed !== "00:00:00" && {feed.elapsed}}
+ {feed.elapsed && feed.elapsed !== '—' && {feed.elapsed}}
);