// 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?.PROJECTS || []; 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 (

Upload

Drop video, audio, or stills — we proxy and index automatically.
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})}
{files.length > 0 && (
Queue {files.length}
{files.map(f => (
{f.name} {f.size}
{f.status === 'error' && (
{f.error}
)}
{f.status === 'done' ? '✓ done' : f.status === 'error' ? '✗ failed' : f.progress + '%'}
))}
)}
); } /* ===== 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