dragonflight/services/web-ui/public/screens-ingest.jsx
Zac Gaetano 328f7b4f31 feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00

605 lines
24 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, Monitors
/* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB → simple upload
const _PART_SIZE = 10 * 1024 * 1024; // 10 MB chunks for multipart
function _xhrPost(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
} else {
let msg = xhr.status + ' ' + xhr.statusText;
try { const j = JSON.parse(xhr.responseText); msg = j.error || j.message || msg; } catch {}
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', url);
xhr.send(formData);
});
}
async function _uploadFile(file, projectId, onProgress) {
const mime = file.type || 'application/octet-stream';
if (file.size <= _SIMPLE_MAX) {
const fd = new FormData();
fd.append('file', file, file.name);
fd.append('filename', file.name);
fd.append('projectId', projectId);
fd.append('contentType', mime);
return _xhrPost('/api/v1/upload/simple', fd,
(loaded, total) => onProgress(Math.round((loaded / total) * 100)));
}
// — Multipart —
const init = await window.ZAMPP_API.fetch('/upload/init', {
method: 'POST',
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
});
const { assetId, uploadId, key } = init;
const totalParts = Math.ceil(file.size / _PART_SIZE);
const parts = [];
for (let i = 0; i < totalParts; i++) {
const chunk = file.slice(i * _PART_SIZE, Math.min((i + 1) * _PART_SIZE, file.size));
const fd = new FormData();
fd.append('file', chunk, file.name);
fd.append('uploadId', uploadId);
fd.append('key', key);
fd.append('partNumber', String(i + 1));
const partRes = await _xhrPost('/api/v1/upload/part', fd,
(loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
}
await window.ZAMPP_API.fetch('/upload/complete', {
method: 'POST',
body: JSON.stringify({ uploadId, key, assetId, parts }),
});
return { id: assetId };
}
/* ===== Upload ===== */
function Upload({ navigate }) {
const { PROJECTS } = window.ZAMPP_DATA;
const [files, setFiles] = React.useState([]);
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
const updateFile = React.useCallback((id, patch) => {
setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
}, []);
const startUpload = React.useCallback((entry, pid) => {
_uploadFile(entry.file, pid, (pct) => updateFile(entry.id, { progress: pct }))
.then(() => updateFile(entry.id, { status: 'done', progress: 100 }))
.catch(e => updateFile(entry.id, { status: 'error', progress: 0, error: e.message }));
}, [updateFile]);
const handleDrop = React.useCallback((e) => {
e.preventDefault();
const dropped = Array.from(e.dataTransfer ? e.dataTransfer.files : e.target.files);
const pid = projectId || PROJECTS[0]?.id || '';
const newEntries = dropped.map((f, i) => ({
id: Date.now() + i,
name: f.name,
size: window.ZAMPP_API.fmtSize(f.size),
file: f,
progress: 0,
status: 'uploading',
error: null,
}));
setFiles(prev => [...prev, ...newEntries]);
newEntries.forEach(entry => startUpload(entry, pid));
}, [projectId, startUpload]);
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>
<select className="field-input" value={projectId} onChange={e => setProjectId(e.target.value)}
style={{ appearance: 'auto' }}>
{PROJECTS.length === 0
? <option value="">No projects</option>
: PROJECTS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
</div>
<div className="dropzone"
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
onClick={() => {
const inp = document.createElement('input');
inp.type = 'file'; inp.multiple = true;
inp.onchange = handleDrop;
inp.click();
}}>
<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</div>
<div className="dropzone-formats">
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
<span key={f} className="badge outline">{f}</span>)}
</div>
</div>
{files.length > 0 && (
<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" onClick={() => setFiles(f => f.filter(x => x.status === 'uploading'))}>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|aiff)$/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)' : f.status === 'error' ? 'var(--danger)' : 'var(--accent)',
transition: 'width 200ms',
}} />
</div>
{f.status === 'error' && (
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--danger)' }}>{f.error}</div>
)}
</div>
<span className="mono" style={{
fontSize: 11.5, minWidth: 60, textAlign: 'right',
color: f.status === 'done' ? 'var(--success)' : f.status === 'error' ? 'var(--danger)' : 'var(--text-3)',
}}>
{f.status === 'done' ? '✓ done'
: f.status === 'error' ? '✗ failed'
: f.progress + '%'}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
/* ===== Live preview (HLS) ====================================
Shared by RecorderRow + MonitorTile. The capture container writes
HLS segments to /live/{assetId}/index.m3u8 (see capture-manager.js
and nginx.conf); we attach hls.js to a <video> when a recorder is
actively recording and has a live asset.
============================================================ */
function HlsPreview({ assetId, muted = true, controls = false, className }) {
const videoRef = React.useRef(null);
const [err, setErr] = React.useState(null);
React.useEffect(() => {
if (!assetId || !videoRef.current) return;
const url = '/live/' + assetId + '/index.m3u8';
const v = videoRef.current;
// Safari can play HLS natively; everything else needs hls.js.
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url;
const onErr = () => setErr('playback failed');
v.addEventListener('error', onErr);
return () => v.removeEventListener('error', onErr);
}
if (!window.Hls) { setErr('hls.js missing'); return; }
const hls = new window.Hls({ liveSyncDurationCount: 2, lowLatencyMode: true });
hls.loadSource(url);
hls.attachMedia(v);
hls.on(window.Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setErr(data.details || 'hls error');
});
return () => { try { hls.destroy(); } catch (_) {} };
}, [assetId]);
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted={muted}
controls={controls}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
{err && (
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-3)', fontSize: 11, background: 'rgba(0,0,0,0.5)' }}>
{err}
</div>
)}
</div>
);
}
/* ===== 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, '00') + ':' +
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);
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(() => {
refresh();
const id = setInterval(refresh, 10000);
return () => clearInterval(id);
}, []);
const liveCount = recorders.filter(r => r.status === 'recording').length;
const errCount = recorders.filter(r => r.status === 'error').length;
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" />
{(liveCount > 0 || errCount > 0) && (
<div className="status-pip">
<span className="dot" />
<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">
{recorders.length === 0 ? (
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>
No recorders configured.
<div style={{ marginTop: 12 }}><button className="btn primary" onClick={onNew}><Icon name="plus" />Add recorder</button></div>
</div>
) : (
<div className="recorders-list">
{recorders.map(r => <RecorderRow key={r.id} recorder={r} onRefresh={refresh} />)}
</div>
)}
</div>
</div>
);
}
function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const [recorder, setRecorder] = React.useState(initialRecorder);
const [pending, setPending] = React.useState(false);
const [err, setErr] = React.useState(null);
const [liveStatus, setLiveStatus] = React.useState(null);
const isRec = recorder.status === 'recording';
React.useEffect(() => { setRecorder(initialRecorder); }, [initialRecorder.id, initialRecorder.status]);
// Poll the status endpoint every 3s while recording for live feedback.
React.useEffect(() => {
if (!isRec) { setLiveStatus(null); return; }
const poll = () => {
window.ZAMPP_API.fetch('/recorders/' + recorder.id + '/status')
.then(s => setLiveStatus(s))
.catch(() => {});
};
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, [isRec, recorder.id]);
const displayElapsed = React.useMemo(() => {
if (liveStatus && liveStatus.duration != null) {
const d = Math.max(0, liveStatus.duration);
return String(Math.floor(d / 3600)).padStart(2, '0') + ':' +
String(Math.floor((d % 3600) / 60)).padStart(2, '0') + ':' +
String(d % 60).padStart(2, '0');
}
return recorder.elapsed;
}, [liveStatus, recorder.elapsed]);
const displaySignal = liveStatus
? (liveStatus.signal || '—')
: (isRec ? 'connecting…' : '—');
const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: displaySignal === 'stopped' ? 'var(--danger)'
: 'var(--text-3)';
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(() => { setPending(false); onRefresh(); })
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const handleDelete = () => {
if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
.then(() => onRefresh())
.catch(e => setErr(e.message || 'Delete failed'));
};
return (
<div className={'recorder-row ' + recorder.status}>
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} />
: 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>
</div>
{err && <div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{err}</div>}
{liveStatus?.lastError && isRec && (
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--danger)' }}>{liveStatus.lastError}</div>
)}
</div>
<div className="recorder-stats">
<div className="recorder-stat">
<div className="stat-label">Elapsed</div>
<div className="stat-val mono">{displayElapsed}</div>
</div>
<div className="recorder-stat">
<div className="stat-label">Signal</div>
<div className="stat-val" style={{ fontSize: 11, color: signalColor }}>{displaySignal}</div>
</div>
{liveStatus?.currentFps != null && (
<div className="recorder-stat">
<div className="stat-label">FPS</div>
<div className="stat-val mono">{Number(liveStatus.currentFps).toFixed(1)}</div>
</div>
)}
</div>
<div className="recorder-actions">
{isRec
? <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" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" />
</button>
</div>
</div>
);
}
function badgeForStatus(s) {
return { recording: 'live', armed: 'accent', idle: 'neutral', error: 'danger', offline: 'neutral', stopped: 'neutral' }[s] || 'neutral';
}
/* ===== Capture ===== */
function Capture({ navigate }) {
const [devices, setDevices] = React.useState([]);
const [activeIdx, setActiveIdx] = React.useState(0);
const loadDevices = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic')
.then(devs => setDevices(Array.isArray(devs) ? devs : []))
.catch(() => setDevices([]));
};
React.useEffect(() => { loadDevices(); }, []);
if (devices.length === 0) {
return (
<div className="page">
<div className="page-header">
<h1>Capture</h1>
<span className="subtitle">DeckLink SDI ingest</span>
<div className="spacer" />
<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)' }}>
No DeckLink devices found in cluster.
</div>
</div>
</div>
);
}
const active = devices[activeIdx] || devices[0];
return (
<div className="page">
<div className="page-header">
<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={loadDevices}><Icon name="refresh" />Refresh</button>
</div>
<div className="page-body">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
{devices.map((d, i) => (
<button key={i} className={'btn ' + (activeIdx === i ? 'primary' : 'ghost') + ' sm'} onClick={() => setActiveIdx(i)}>
{d.model || d.device || 'DeckLink'} {d.hostname}
</button>
))}
</div>
<div className="panel" style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<StatusDot status={active.online ? 'online' : 'offline'} />
<div>
<div style={{ fontWeight: 600 }}>{active.model || active.device || 'DeckLink'}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{active.hostname} · {active.ip_address}</div>
</div>
</div>
<div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Connect a source and click Refresh to see port status.</div>
</div>
</div>
</div>
);
}
/* ===== Monitors ===== */
function Monitors({ navigate }) {
const [recorders, setRecorders] = React.useState(window.ZAMPP_DATA.RECORDERS);
const [grid, setGrid] = React.useState(4);
React.useEffect(() => {
const refresh = () => {
window.ZAMPP_API.fetch('/recorders')
.then(raw => {
const norm = (raw || []).map(_normRecorder);
window.ZAMPP_DATA.RECORDERS = norm;
setRecorders(norm);
})
.catch(() => {});
};
refresh();
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];
const feeds = allFeeds.slice(0, grid * grid);
return (
<div className="page">
<div className="page-header">
<h1>Monitors</h1>
<span className="subtitle">Multi-cam live monitoring</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>
</div>
<div className="page-body">
{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">
<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={levels[0]} vertical />
<AudioMeter level={levels[1]} 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">
{isLive && feed.live_asset_id
? <HlsPreview assetId={feed.live_asset_id} />
: <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 }}>
{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>}
</div>
</div>
);
}
Object.assign(window, { Upload, Recorders, Capture, Monitors });