diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index e221b36..703355a 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -392,6 +392,11 @@ class CaptureManager { this.state.sessionId = null; this.state.processes = {}; + // No frames received → the upload (if any) produced a 0-byte object. + // Surface that so the shutdown handler can mark the asset as 'error' + // instead of posting a broken hi-res key downstream. + const framesReceived = this.state.framesReceived; + return { sessionId, projectId: currentSession.projectId, @@ -404,6 +409,8 @@ class CaptureManager { startedAt: currentSession.startedAt, stoppedAt, duration, + framesReceived, + empty: framesReceived === 0, }; } diff --git a/services/capture/src/index.js b/services/capture/src/index.js index c626f18..a56f115 100644 --- a/services/capture/src/index.js +++ b/services/capture/src/index.js @@ -113,31 +113,52 @@ async function gracefulShutdown(signal) { console.log(`[shutdown] stopping active session ${status.sessionId}...`); try { const completed = await captureManager.stop(status.sessionId); - console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`); + console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`); - try { - const res = await fetch(`${MAM_API_URL}/api/v1/assets`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - projectId: completed.projectId, - binId: completed.binId, - clipName: completed.clipName, - sourceType: completed.sourceType, - hiresKey: completed.hiresKey, - proxyKey: completed.proxyKey, - needsProxy: completed.proxyKey === null, - duration: completed.duration, - capturedAt: completed.startedAt, - }), - }); - if (!res.ok) { - console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`); - } else { - console.log('[shutdown] asset registered with mam-api'); + const liveAssetId = process.env.ASSET_ID || null; + + // No frames received → the source never connected (bad SRT URL, dead + // SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this + // point is 0 bytes and would just clog the proxy queue with "moov + // atom not found" failures. Mark the pre-created live asset as + // 'error' and skip the POST /assets registration entirely. + if (completed.empty) { + console.warn('[shutdown] no frames received — marking asset as error and skipping registration'); + if (liveAssetId) { + try { + await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + } catch (e) { + console.error('[shutdown] failed to flag empty asset:', e.message); + } + } + } else { + try { + const res = await fetch(`${MAM_API_URL}/api/v1/assets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: completed.projectId, + binId: completed.binId, + clipName: completed.clipName, + sourceType: completed.sourceType, + hiresKey: completed.hiresKey, + proxyKey: completed.proxyKey, + needsProxy: completed.proxyKey === null, + duration: completed.duration, + capturedAt: completed.startedAt, + }), + }); + if (!res.ok) { + console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`); + } else { + console.log('[shutdown] asset registered with mam-api'); + } + } catch (mamErr) { + console.error('[shutdown] failed to register asset:', mamErr.message); } - } catch (mamErr) { - console.error('[shutdown] failed to register asset:', mamErr.message); } } catch (err) { console.error('[shutdown] error during stop:', err); diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 6078080..f1de1aa 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -302,6 +302,26 @@ router.post('/:id/copy', async (req, res, next) => { } }); +// POST /:id/mark-empty — flag a pre-created live asset as 'error' because +// the recorder finished a session without any frames (bad source URL, dead +// SDI signal, etc.). Called by the capture sidecar's shutdown handler. +router.post('/:id/mark-empty', async (req, res, next) => { + try { + const { id } = req.params; + const result = await pool.query( + `UPDATE assets + SET status = 'error', + notes = COALESCE(notes || E'\\n', '') || 'Recording produced no frames — source never connected.', + updated_at = NOW() + WHERE id = $1 AND status = 'live' + RETURNING id`, + [id] + ); + if (result.rows.length === 0) return res.status(404).json({ error: 'No matching live asset' }); + res.json({ id }); + } catch (err) { next(err); } +}); + // POST /:id/generate-proxy — re-queue proxy for an asset that has a hi-res // master but no proxy_s3_key. Used to backfill recorder-captured clips that // pre-date the auto-proxy-on-finalize fix. diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 3481344..a083d5d 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -173,3 +173,6 @@ async function loadData() { } window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative }; +// Library re-renders after mutations: expose normalizeAsset so the screen +// can re-fetch /assets and produce rows with the same shape as the boot load. +window.normalizeAsset = normalizeAsset; diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 5e3655b..5997870 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -1,14 +1,53 @@ // screens-library.jsx function Library({ navigate, onOpenAsset, openProject }) { - const { ASSETS: ALL_ASSETS, BINS, PROJECTS } = window.ZAMPP_DATA; + const { BINS, PROJECTS } = window.ZAMPP_DATA; const [view, setView] = React.useState('grid'); const [filter, setFilter] = React.useState('all'); const [search, setSearch] = React.useState(''); + // Local state lets us re-render after delete / move-to-bin without forcing + // a full app reload — keeps ZAMPP_DATA in sync as the cache of record. + const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []); + const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y } + + const refreshAssets = React.useCallback(() => { + window.ZAMPP_API.fetch('/assets?limit=500') + .then(r => { + const list = Array.isArray(r) ? r : (r.assets || []); + const proj = {}; + (PROJECTS || []).forEach(p => { proj[p.id] = p.name; }); + const normalized = list.map(a => window.normalizeAsset ? window.normalizeAsset(a, proj) : a); + window.ZAMPP_DATA.ASSETS = normalized; + setAllAssets(normalized); + }) + .catch(() => {}); + }, [PROJECTS]); + + // Dismiss the context menu on any outside click (capture phase so clicking + // a menu item still fires before the menu unmounts). + React.useEffect(() => { + if (!ctxMenu) return; + const close = () => setCtxMenu(null); + window.addEventListener('click', close); + window.addEventListener('contextmenu', close); + window.addEventListener('scroll', close, true); + return () => { + window.removeEventListener('click', close); + window.removeEventListener('contextmenu', close); + window.removeEventListener('scroll', close, true); + }; + }, [ctxMenu]); + + const openCtx = (asset, e) => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ asset, x: e.clientX, y: e.clientY }); + }; let assets = openProject - ? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; }) - : ALL_ASSETS; + ? allAssets.filter(function(a) { return a.project_id === openProject.id; }) + : allAssets; + const ALL_ASSETS = allAssets; if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; }); if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); }); @@ -94,7 +133,11 @@ function Library({ navigate, onOpenAsset, openProject }) {
No assets match this filter.
) : view === 'grid' ? (
- {assets.map(function(a) { return ; })} + {assets.map(function(a) { + return ; + })}
) : (
@@ -103,7 +146,7 @@ function Library({ navigate, onOpenAsset, openProject }) {
{assets.map(function(a) { return ( -
+
{a.name}
@@ -117,18 +160,110 @@ function Library({ navigate, onOpenAsset, openProject }) {
{a.codec || '—'}
{a.size}
{a.updated}
- +
); })}
)}
+ {ctxMenu && ( + + )} ); } -function AssetCard({ asset, onOpen }) { +function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) { + const ref = React.useRef(null); + // Pin the menu inside the viewport even if the user right-clicked near + // the bottom-right edge of the grid. + const [pos, setPos] = React.useState({ left: x, top: y }); + React.useLayoutEffect(() => { + if (!ref.current) return; + const r = ref.current.getBoundingClientRect(); + const margin = 8; + let nx = x, ny = y; + if (x + r.width + margin > window.innerWidth) nx = window.innerWidth - r.width - margin; + if (y + r.height + margin > window.innerHeight) ny = window.innerHeight - r.height - margin; + setPos({ left: Math.max(margin, nx), top: Math.max(margin, ny) }); + }, [x, y]); + + const rename = function() { + onClose(); + const next = prompt('Rename asset', asset.display_name || asset.name || ''); + if (next == null) return; + const trimmed = next.trim(); + if (!trimmed || trimmed === (asset.display_name || asset.name)) return; + window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) }) + .then(onChanged) + .catch(function(e) { alert('Rename failed: ' + e.message); }); + }; + + const moveToBin = function(binId) { + onClose(); + window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) }) + .then(onChanged) + .catch(function(e) { alert('Move failed: ' + e.message); }); + }; + + const copyId = function() { + onClose(); + if (navigator.clipboard) navigator.clipboard.writeText(asset.id).catch(function() {}); + }; + + const remove = function() { + onClose(); + if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return; + window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' }) + .then(onChanged) + .catch(function(e) { alert('Delete failed: ' + e.message); }); + }; + + return ( +
+
{asset.display_name || asset.name}
+ + +
+ {(bins && bins.length > 0) ? ( + <> +
Move to bin
+ {bins.slice(0, 10).map(function(b) { + const isCurrent = asset.bin_id === b.id; + return ( + + ); + })} + {asset.bin_id && ( + + )} + + ) : ( +
No bins — create one in a project
+ )} +
+ + +
+ ); +} + +function AssetCard({ asset, onOpen, onContextMenu }) { const [hoverStream, setHoverStream] = React.useState(null); const [hovered, setHovered] = React.useState(false); const timerRef = React.useRef(null); @@ -166,7 +301,7 @@ function AssetCard({ asset, onOpen }) { const showVideo = hovered && hoverStream; return ( -
+
{showVideo && ( diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css index 0e3e56f..45fa35d 100644 --- a/services/web-ui/public/styles-rest.css +++ b/services/web-ui/public/styles-rest.css @@ -564,6 +564,67 @@ } /* ========== Admin tables ========== */ +/* ── Right-click context menu (Library, etc.) ─────────────────────── */ +.ctx-menu { + position: fixed; + z-index: 1000; + min-width: 200px; + max-width: 280px; + background: var(--bg-1); + border: 1px solid var(--border-stronger); + border-radius: 6px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45); + padding: 4px; + display: flex; + flex-direction: column; + user-select: none; +} +.ctx-menu .ctx-header { + padding: 6px 10px 4px; + font-size: 11px; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.06em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ctx-menu .ctx-section-label { + padding: 6px 10px 2px; + font-size: 10.5px; + color: var(--text-4); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.ctx-menu .ctx-divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} +.ctx-menu .ctx-empty { + padding: 6px 10px; + font-size: 11.5px; + color: var(--text-3); + font-style: italic; +} +.ctx-menu button { + display: flex; + align-items: center; + gap: 8px; + background: transparent; + border: 0; + color: var(--text-1); + font-size: 12.5px; + padding: 7px 10px; + border-radius: 4px; + cursor: pointer; + text-align: left; +} +.ctx-menu button:hover:not(:disabled) { background: var(--bg-3); } +.ctx-menu button:disabled { opacity: 0.45; cursor: default; } +.ctx-menu button.danger { color: var(--danger); } +.ctx-menu button.danger:hover { background: var(--danger-soft); } + /* ── Row popover menu (Users, etc.) ────────────────────────────────── */ .row-menu { position: absolute; diff --git a/services/worker/src/workers/proxy.js b/services/worker/src/workers/proxy.js index 784466f..95f8467 100644 --- a/services/worker/src/workers/proxy.js +++ b/services/worker/src/workers/proxy.js @@ -1,5 +1,5 @@ import { join } from 'path'; -import { unlink } from 'fs/promises' +import { stat, unlink } from 'fs/promises'; import { tmpdir } from 'os'; import { Queue } from 'bullmq'; import { query } from '../db/client.js'; @@ -66,6 +66,20 @@ export const proxyWorker = async (job) => { console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`); await downloadFromS3(S3_BUCKET, inputKey, inputPath); + // Reject obviously-empty inputs before handing them to ffmpeg. Aborted + // SRT/RTMP recordings end up as 0-byte (or ftyp-only ~1KB) objects in S3 + // when the source disconnects before any frame is received; the proxy + // pipeline used to bomb on "moov atom not found", which buried the + // real reason. Bail with a clear message and let the asset go to 'error'. + const { size: inputBytes } = await stat(inputPath); + if (inputBytes < 4096) { + throw new Error( + `Source is empty or truncated (${inputBytes} bytes). The recording ` + + `likely ended before any frames were received — check the source ` + + `URL / SDI signal and re-record.` + ); + } + // Extract source metadata (fps, codec, resolution, duration, size, audio) await job.updateProgress(20); let mediaInfo = {};