dragonflight/services/web-ui/public/screens-ingest.jsx

396 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// screens-ingest.jsx — Upload, Recorders, Capture (SDI), Monitors
const { RECORDERS, NODES, SDI_PORTS_zampp2, PROJECTS } = window.ZAMPP_DATA;
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" },
]);
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);
}, []);
return (
<div className="page">
<div className="page-header">
<h1>Upload</h1>
<span className="subtitle">Drop video, audio, or stills we proxy and index automatically.</span>
</div>
<div className="page-body">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
<div>
<label className="field-label">Project</label>
<div className="select-faux"><span>Protour 2026</span><Icon name="chevronDown" size={12} /></div>
</div>
<div>
<label className="field-label">Bin</label>
<div className="select-faux"><span>Master files</span><Icon name="chevronDown" size={12} /></div>
</div>
</div>
<div className="dropzone">
<Icon name="upload" size={32} style={{ color: "var(--text-3)" }} />
<div style={{ fontSize: 15, fontWeight: 500 }}>Drop files here or click to browse</div>
<div className="muted" style={{ fontSize: 12.5 }}>Video, audio, and image files up to 5 GB each</div>
<div className="dropzone-formats">
{["MOV", "MP4", "MXF", "ProRes", "DNxHR", "WAV", "AIFF"].map(f => <span key={f} className="badge outline">{f}</span>)}
</div>
</div>
<div style={{ marginTop: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12, display: "flex", alignItems: "center", gap: 8 }}>
Queue
<span className="badge neutral">{files.length}</span>
<span style={{ flex: 1 }} />
<button className="btn ghost sm">Pause all</button>
<button className="btn ghost sm">Clear done</button>
</div>
<div className="panel">
{files.map(f => (
<div key={f.id} className="upload-row">
<Icon name={f.name.match(/\.(wav|aif|mp3)$/i) ? "audio" : "video"} size={16} style={{ color: "var(--text-3)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontWeight: 500, fontSize: 12.5 }}>{f.name}</span>
<span className="muted" style={{ fontSize: 11, fontFamily: "var(--font-mono)" }}>{f.size}</span>
</div>
<div style={{ marginTop: 6, height: 4, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{
width: `${f.progress}%`, height: "100%",
background: f.status === "done" ? "var(--success)" : "var(--accent)",
transition: "width 200ms",
}} />
</div>
</div>
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 60, textAlign: "right" }}>
{f.status === "done" ? "✓ done" : f.status === "queued" ? "queued" : `${Math.round(f.progress)}%`}
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
function Recorders({ navigate, onNew }) {
return (
<div className="page">
<div className="page-header">
<h1>Recorders</h1>
<span className="subtitle">Live ingest from SRT, RTMP, and SDI sources</span>
<div className="spacer" />
<div className="status-pip">
<span className="dot" />
<span>4 recording · 1 armed · 1 error</span>
</div>
<button className="btn primary" onClick={onNew}><Icon name="plus" />New recorder</button>
</div>
<div className="page-body">
<div className="recorders-list">
{RECORDERS.map(r => <RecorderRow key={r.id} recorder={r} />)}
</div>
</div>
</div>
);
}
function RecorderRow({ recorder }) {
const isRec = recorder.status === "recording";
return (
<div className={`recorder-row ${recorder.status}`}>
<div className="recorder-preview">
{recorder.audio ? (
<div className="recorder-audio-prev">
<Waveform seed={recorder.id.length} />
<AudioMeter level={0.7} />
</div>
) : isRec ? (
<LiveStrip seed={recorder.id.length * 3} count={6} />
) : (
<div className="recorder-empty">
<Icon name={recorder.status === "error" ? "alert" : "video"} size={20} style={{ opacity: 0.4 }} />
</div>
)}
</div>
<div className="recorder-info">
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{recorder.name}</span>
<span className={`badge ${badgeForStatus(recorder.status)}`}>
<StatusDot status={recorder.status} /> {recorder.status.toUpperCase()}
</span>
<span className="badge outline">{recorder.source}</span>
</div>
<div className="recorder-sub mono">{recorder.url}</div>
<div className="recorder-sub">
<span>{recorder.codec}</span><span>·</span>
<span>{recorder.res}</span><span>·</span>
<span>node: {recorder.node}</span>
</div>
</div>
<div className="recorder-stats">
<div className="recorder-stat">
<div className="stat-label">Elapsed</div>
<div className="stat-val mono">{recorder.elapsed}</div>
</div>
<div className="recorder-stat">
<div className="stat-label">Bitrate</div>
<div className="stat-val mono">{recorder.bitrate}</div>
</div>
<div className="recorder-stat" style={{ minWidth: 80 }}>
<div className="stat-label">Health</div>
<div className="stat-val">
<HealthBar value={recorder.health} />
</div>
</div>
</div>
<div className="recorder-actions">
{isRec ? (
<button className="btn danger sm"><span className="rec-dot" />Stop</button>
) : recorder.status === "error" ? (
<button className="btn subtle sm"><Icon name="refresh" />Reconnect</button>
) : (
<button className="btn subtle sm"><span className="rec-dot" style={{ background: "var(--live)" }} />Record</button>
)}
<button className="icon-btn"><Icon name="more" /></button>
</div>
</div>
);
}
function HealthBar({ value }) {
const color = value > 80 ? "var(--success)" : value > 40 ? "var(--warning)" : "var(--danger)";
return (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div style={{ width: 56, height: 5, background: "var(--bg-3)", borderRadius: 99, overflow: "hidden" }}>
<div style={{ width: `${value}%`, height: "100%", background: color }} />
</div>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{value}%</span>
</div>
);
}
function badgeForStatus(s) {
return { recording: "live", armed: "accent", idle: "neutral", error: "danger", offline: "neutral" }[s] || "neutral";
}
function Capture({ navigate }) {
const [activePort, setActivePort] = React.useState(1);
const ports = SDI_PORTS_zampp2;
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest multi-port routing across cluster nodes</span>
<div className="spacer" />
<button className="btn ghost sm"><Icon name="refresh" />Refresh ports</button>
<button className="btn primary"><Icon name="plus" />New capture</button>
</div>
<div className="page-body">
<div style={{ display: "grid", gridTemplateColumns: "440px 1fr", gap: 20, alignItems: "start" }}>
<DeckLinkVisual ports={ports} activePort={activePort} onSelect={setActivePort} />
<CaptureDetail port={ports[activePort - 1]} />
</div>
</div>
</div>
);
}
function DeckLinkVisual({ ports, activePort, onSelect }) {
return (
<div className="decklink-card">
<div className="decklink-head">
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>DeckLink Duo 2</div>
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>zampp2 · 172.18.91.217</div>
</div>
<span className="badge success"><StatusDot status="online" /> ONLINE</span>
</div>
<div className="decklink-body">
<div className="decklink-card-face">
<div className="decklink-label">DECKLINK DUO 2</div>
<div className="decklink-leds">
<div className="decklink-led on" />
<div className="decklink-led" />
</div>
</div>
<div className="bnc-ports">
{ports.map(p => (
<button
key={p.idx}
className={`bnc-port ${activePort === p.idx ? "active" : ""} ${p.active ? "live" : ""} ${p.recording ? "recording" : ""}`}
onClick={() => onSelect(p.idx)}
>
<div className="bnc-connector">
<div className="bnc-pin" />
<div className="bnc-ring" />
</div>
<div className="bnc-info">
<div className="bnc-num">SDI {p.idx}</div>
<div className="bnc-label">{p.label}</div>
<div className="bnc-sig mono">{p.signal}</div>
</div>
{p.recording && <span className="bnc-rec" />}
{p.active && (
<div className="bnc-signal-bar">
<div className="bnc-signal-fill" style={{ width: `${p.level * 100}%` }} />
</div>
)}
</button>
))}
</div>
</div>
<div className="decklink-foot">
<span>Ports: <strong>4</strong></span>
<span>Active: <strong>{ports.filter(p => p.active).length}</strong></span>
<span>Recording: <strong style={{ color: "var(--live)" }}>{ports.filter(p => p.recording).length}</strong></span>
</div>
</div>
);
}
function CaptureDetail({ port }) {
if (!port.active) {
return (
<div className="capture-detail empty">
<Icon name="video" size={28} style={{ opacity: 0.3 }} />
<div style={{ marginTop: 8, fontWeight: 500 }}>No signal on SDI {port.idx}</div>
<div className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>Connect a source, then click Refresh.</div>
</div>
);
}
return (
<div className="capture-detail">
<div className="capture-preview">
<FauxFrame seed={port.idx} />
<div className="scanlines" />
{port.recording && (
<div style={{ position: "absolute", top: 12, left: 12 }}>
<span className="badge live">REC</span>
</div>
)}
<div className="capture-overlay-meters">
<AudioMeter level={0.7} vertical />
<AudioMeter level={0.6} vertical />
</div>
<div className="capture-tc">
<span className="mono">{port.signal}</span>
</div>
</div>
<div className="capture-stats">
<CaptureStat label="Resolution" value={port.signal.split(/[ip]/)[0] + (port.signal.includes("p") ? "p" : "i")} />
<CaptureStat label="Frame rate" value={port.signal.match(/[\d.]+$/)?.[0] || "—"} />
<CaptureStat label="Color" value="YUV 4:2:2 10-bit" />
<CaptureStat label="Audio" value="2.0 / 48 kHz" />
<CaptureStat label="Source level" value={`${Math.round(port.level * 100)}%`} />
<CaptureStat label="Dropped frames" value="0" />
</div>
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
{!port.recording ? (
<button className="btn primary"><span className="rec-dot" style={{ background: "white" }} />Arm + Record</button>
) : (
<button className="btn danger"><span className="rec-dot" />Stop</button>
)}
<button className="btn subtle">Take still</button>
<button className="btn ghost">Route to recorder</button>
</div>
</div>
);
}
function CaptureStat({ label, value }) {
return (
<div className="capture-stat">
<div className="capture-stat-label">{label}</div>
<div className="capture-stat-value mono">{value}</div>
</div>
);
}
function Monitors({ navigate }) {
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 feeds = allFeeds.slice(0, grid * grid - (grid === 2 ? 1 : 0));
const gridSize = grid;
return (
<div className="page">
<div className="page-header">
<h1>Monitors</h1>
<span className="subtitle">Multi-cam live monitoring across all active feeds</span>
<div className="spacer" />
<div className="tab-group">
{[2, 3, 4].map(n => (
<button key={n} className={grid === n ? "active" : ""} onClick={() => setGrid(n)}>{n}×{n}</button>
))}
</div>
<button className="btn ghost sm"><Icon name="layout" />Layouts</button>
</div>
<div className="page-body">
<div className="monitors-grid" style={{ gridTemplateColumns: `repeat(${gridSize}, 1fr)` }}>
{feeds.map((f, i) => (
<MonitorTile key={f.id} feed={f} seed={i + 1} />
))}
</div>
</div>
</div>
);
}
function MonitorTile({ feed, seed }) {
if (feed.kind === "audio") {
return (
<div className="monitor-tile audio">
<div style={{ flex: 1, display: "grid", placeItems: "center", padding: 24 }}>
<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 />
</div>
<div className="monitor-tile-label">
<span className="badge live">LIVE</span>
<span className="name">{feed.name}</span>
</div>
</div>
);
}
return (
<div className="monitor-tile">
<FauxFrame seed={seed} />
<div className="scanlines" />
<div style={{ position: "absolute", top: 8, left: 8, display: "flex", gap: 6 }}>
{feed.status === "recording" && <span className="badge live">REC</span>}
{feed.status === "armed" && <span className="badge accent">ARMED</span>}
{feed.status === "error" && <span className="badge danger">ERR</span>}
{feed.status === "idle" && <span className="badge neutral">IDLE</span>}
</div>
<div className="monitor-tile-label">
<span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== "00:00:00" && <span className="time mono">{feed.elapsed}</span>}
</div>
</div>
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors });