From 26399f8d0a20328a763216615789b0f62aa99b77 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Fri, 22 May 2026 11:10:00 -0400 Subject: [PATCH] fix: implement real upload (XHR + S3 multipart) and fix SDI recorder device_index + manual fallback: screens-ingest.jsx --- services/web-ui/public/screens-ingest.jsx | 175 ++++++++++++++++------ 1 file changed, 128 insertions(+), 47 deletions(-) diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 44ee642..0ca3054 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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 (
@@ -33,20 +108,30 @@ function Upload({ navigate }) {
-
- {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(); }}> +
e.preventDefault()} + onClick={() => { + const inp = document.createElement('input'); + inp.type = 'file'; inp.multiple = true; + inp.onchange = handleDrop; + inp.click(); + }}>
Drop files here or click to browse
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})}
@@ -55,23 +140,36 @@ function Upload({ navigate }) {
Queue {files.length} - +
{files.map(f => (
- +
{f.name} {f.size}
-
+
+ {f.status === 'error' && ( +
{f.error}
+ )}
- - {f.status === 'done' ? '✓ done' : f.status === 'queued' ? 'queued' : Math.round(f.progress) + '%'} + + {f.status === 'done' ? '✓ done' + : f.status === 'error' ? '✗ failed' + : f.progress + '%'}
))} @@ -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 ( -
-
-
-
- {value}% -
- ); -} - /* ===== 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 (
- {isLive && ( -
- )} + {isLive &&
}
- {isLive && REC} - {feed.status === 'stopped' && IDLE} - {feed.status === 'idle' && IDLE} - {feed.status === 'error' && ERR} + {isLive && REC} + {feed.status === 'stopped' && IDLE} + {feed.status === 'idle' && IDLE} + {feed.status === 'error' && ERR}
{isLive && (