fix: implement real upload (XHR + S3 multipart) and fix SDI recorder device_index + manual fallback: screens-ingest.jsx
This commit is contained in:
parent
529d14cb6b
commit
26399f8d0a
1 changed files with 128 additions and 47 deletions
|
|
@ -1,27 +1,102 @@
|
|||
// 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 [project, setProject] = React.useState('');
|
||||
const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
const { PROJECTS } = window.ZAMPP_DATA;
|
||||
if (PROJECTS.length > 0 && !project) setProject(PROJECTS[0].id);
|
||||
const updateFile = React.useCallback((id, patch) => {
|
||||
setFiles(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f));
|
||||
}, []);
|
||||
|
||||
const handleDrop = (e) => {
|
||||
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 newFiles = dropped.map((f, i) => ({
|
||||
id: Date.now() + i, name: f.name,
|
||||
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: 'queued',
|
||||
file: f,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
error: null,
|
||||
}));
|
||||
setFiles(prev => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
const { PROJECTS } = window.ZAMPP_DATA;
|
||||
setFiles(prev => [...prev, ...newEntries]);
|
||||
newEntries.forEach(entry => startUpload(entry, pid));
|
||||
}, [projectId, startUpload]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
|
@ -33,20 +108,30 @@ function Upload({ navigate }) {
|
|||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||||
<div>
|
||||
<label className="field-label">Project</label>
|
||||
<div className="select-faux">
|
||||
<span>{PROJECTS.find(p => p.id === project)?.name || (PROJECTS.length ? PROJECTS[0].name : 'No projects')}</span>
|
||||
<Icon name="chevronDown" size={12} />
|
||||
</div>
|
||||
<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 i = document.createElement('input'); i.type='file'; i.multiple=true; i.onchange=handleDrop; i.click(); }}>
|
||||
<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>)}
|
||||
{['MOV', 'MP4', 'MXF', 'ProRes', 'DNxHR', 'WAV', 'AIFF'].map(f =>
|
||||
<span key={f} className="badge outline">{f}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -55,23 +140,36 @@ function Upload({ navigate }) {
|
|||
<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 !== 'done'))}>Clear done</button>
|
||||
<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)$/i) ? 'audio' : 'video'} size={16} style={{ color: 'var(--text-3)' }} />
|
||||
<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)' : 'var(--accent)', transition: 'width 200ms' }} />
|
||||
<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, color: 'var(--text-3)', minWidth: 60, textAlign: 'right' }}>
|
||||
{f.status === 'done' ? '✓ done' : f.status === 'queued' ? 'queued' : Math.round(f.progress) + '%'}
|
||||
<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>
|
||||
))}
|
||||
|
|
@ -228,18 +326,6 @@ 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)';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== Capture ===== */
|
||||
function Capture({ navigate }) {
|
||||
const [devices, setDevices] = React.useState([]);
|
||||
|
|
@ -325,10 +411,7 @@ function Monitors({ navigate }) {
|
|||
|
||||
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 allFeeds = [...videoFeeds.map(r => ({ ...r, kind: 'video' })), ...audioFeeds];
|
||||
const feeds = allFeeds.slice(0, grid * grid);
|
||||
|
||||
return (
|
||||
|
|
@ -389,14 +472,12 @@ function MonitorTile({ feed, seed }) {
|
|||
return (
|
||||
<div className="monitor-tile">
|
||||
<FauxFrame />
|
||||
{isLive && (
|
||||
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none', borderRadius: 'inherit' }} />
|
||||
)}
|
||||
{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>}
|
||||
{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 }}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue