// 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 }); window.dispatchEvent(new CustomEvent('df:assets-changed')); }) .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 + '%'}
))}
)}
); } /* ===== YouTube importer ===== */ // Accept the same three URL shapes the API validates against. const _YT_PATTERNS = [ /^https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^ ]*v=[A-Za-z0-9_-]{11}/i, /^https?:\/\/youtu\.be\/[A-Za-z0-9_-]{11}/i, /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]{11}/i, ]; function _looksLikeYouTube(s) { return typeof s === 'string' && _YT_PATTERNS.some(re => re.test(s.trim())); } function YouTubeImport({ navigate }) { const PROJECTS = window.ZAMPP_DATA?.PROJECTS || []; const [projectId, setProjectId] = React.useState(PROJECTS[0]?.id || ''); const [url, setUrl] = React.useState(''); const [queue, setQueue] = React.useState([]); // { id, url, status, progress, title, error, assetId, jobId } const [submitting, setSubmitting] = React.useState(false); const valid = _looksLikeYouTube(url); const updateRow = React.useCallback((id, patch) => { setQueue(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r)); }, []); // Poll the asset row to pick up the title once yt-dlp resolves it, and the // proxy job's progress so the queue row reflects the full lifecycle, not // just the import step. const pollRow = React.useCallback((row) => { if (!row.assetId) return; let stopped = false; const tick = async () => { if (stopped) return; try { const asset = await window.ZAMPP_API.fetch('/assets/' + row.assetId); const patch = {}; if (asset.display_name && asset.display_name !== row.url) patch.title = asset.display_name; if (asset.status === 'ready') { patch.status = 'done'; patch.progress = 100; window.dispatchEvent(new CustomEvent('df:assets-changed')); } else if (asset.status === 'error') { patch.status = 'error'; patch.error = patch.error || 'Import failed — check the Jobs screen for details.'; } else if (asset.status === 'processing') { patch.status = 'processing'; } if (Object.keys(patch).length) updateRow(row.id, patch); if (asset.status === 'ready' || asset.status === 'error') return; } catch { /* ignore */ } setTimeout(tick, 3000); }; tick(); return () => { stopped = true; }; }, [updateRow]); const submit = React.useCallback(async () => { if (!valid || !projectId || submitting) return; setSubmitting(true); const rowId = Date.now(); const row = { id: rowId, url: url.trim(), status: 'queued', progress: 0, title: '', error: null, assetId: null, jobId: null, }; setQueue(prev => [row, ...prev]); try { const res = await window.ZAMPP_API.fetch('/imports/youtube', { method: 'POST', body: JSON.stringify({ url: row.url, projectId }), }); updateRow(rowId, { assetId: res.assetId, jobId: res.jobId, status: 'downloading' }); pollRow({ ...row, assetId: res.assetId, jobId: res.jobId }); setUrl(''); } catch (e) { updateRow(rowId, { status: 'error', error: e.message || 'Failed to start import' }); } finally { setSubmitting(false); } }, [valid, projectId, submitting, url, updateRow, pollRow]); return (

YouTube

Paste a link — we download and import the best available MP4.
setUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} placeholder="https://www.youtube.com/watch?v=… or https://youtu.be/…" style={{ flex: 1 }} autoFocus />
{url && !valid && (
That doesn't look like a YouTube URL.
)}
Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported.
{queue.length > 0 && (
Queue {queue.length}
{queue.map(r => { const statusColor = r.status === 'done' ? 'var(--success)' : r.status === 'error' ? 'var(--danger)' : 'var(--text-3)'; return (
{r.title || r.url}
{r.title && (
{r.url}
)}
{r.error && (
{r.error}
)}
{r.status === 'done' ? '✓ done' : r.status === 'error' ? '✗ failed' : r.status === 'processing'? 'processing' : r.status === 'downloading' ? 'downloading' : 'queued'}
); })}
)}
); } /* ===== 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