fix(recorders,library): empty-capture handling + right-click context menu

Proxy failures ("moov atom not found"):
- root cause: failed/aborted SRT/RTMP recordings still uploaded 0-byte
  (or ftyp-only ~1KB) objects to S3, which ffmpeg can't probe
- worker proxy.js now bails on inputs < 4 KiB with a clear message
  before handing the file to ffmpeg
- capture-manager.stop() returns framesReceived + empty flag
- capture shutdown handler skips POST /assets entirely on empty
  sessions, instead calls new POST /assets/:id/mark-empty to flip
  the pre-created live asset to 'error' with a note

Library asset right-click menu:
- new AssetContextMenu component on screens-library.jsx; right-click
  any asset in grid or list view to open
- actions: Open, Rename, Move to bin (lists up to 10 bins), Remove
  from bin, Copy asset ID, Delete permanently (hard=true)
- viewport-aware positioning (won't clip past window edges)
- dismisses on outside click / contextmenu / scroll
- Library now refreshes via /assets after mutations; normalizeAsset
  exposed on window so the re-fetch shape matches boot
- ctx-menu styles in styles-rest.css
This commit is contained in:
claude 2026-05-23 03:52:30 +00:00
parent 9877ed351f
commit 992fbdfa20
7 changed files with 293 additions and 32 deletions

View file

@ -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,
};
}

View file

@ -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);

View file

@ -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.

View file

@ -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;

View file

@ -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 }) {
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No assets match this filter.</div>
) : view === 'grid' ? (
<div className="library-grid">
{assets.map(function(a) { return <AssetCard key={a.id} asset={a} onOpen={function() { onOpenAsset(a); }} />; })}
{assets.map(function(a) {
return <AssetCard key={a.id} asset={a}
onOpen={function() { onOpenAsset(a); }}
onContextMenu={function(e) { openCtx(a, e); }} />;
})}
</div>
) : (
<div className="library-list">
@ -103,7 +146,7 @@ function Library({ navigate, onOpenAsset, openProject }) {
</div>
{assets.map(function(a) {
return (
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} style={{ cursor: 'pointer' }}>
<div key={a.id} className="list-row" onClick={function() { onOpenAsset(a); }} onContextMenu={function(e) { openCtx(a, e); }} style={{ cursor: 'pointer' }}>
<div className="thumb"><AssetThumb asset={a} /></div>
<div>
<div className="name">{a.name}</div>
@ -117,18 +160,110 @@ function Library({ navigate, onOpenAsset, openProject }) {
<div className="col-sub">{a.codec || '—'}</div>
<div className="col-sub">{a.size}</div>
<div className="col-sub">{a.updated}</div>
<button className="icon-btn" onClick={function(e) { e.stopPropagation(); }}><Icon name="more" /></button>
<button className="icon-btn" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
</div>
);
})}
</div>
)}
</div>
{ctxMenu && (
<AssetContextMenu
asset={ctxMenu.asset}
x={ctxMenu.x}
y={ctxMenu.y}
bins={BINS}
onClose={function() { setCtxMenu(null); }}
onChanged={refreshAssets}
onOpen={function() { onOpenAsset(ctxMenu.asset); }}
/>
)}
</div>
);
}
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 (
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
onClick={function(e) { e.stopPropagation(); }}
onContextMenu={function(e) { e.preventDefault(); e.stopPropagation(); }}>
<div className="ctx-header">{asset.display_name || asset.name}</div>
<button onClick={function() { onClose(); onOpen(); }}><Icon name="play" size={11} />Open</button>
<button onClick={rename}><Icon name="edit" size={11} />Rename</button>
<div className="ctx-divider" />
{(bins && bins.length > 0) ? (
<>
<div className="ctx-section-label">Move to bin</div>
{bins.slice(0, 10).map(function(b) {
const isCurrent = asset.bin_id === b.id;
return (
<button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>
<Icon name="folder" size={11} />{b.name}{isCurrent && <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-3)' }}>current</span>}
</button>
);
})}
{asset.bin_id && (
<button onClick={function() { moveToBin(null); }}>
<Icon name="x" size={11} />Remove from bin
</button>
)}
</>
) : (
<div className="ctx-empty">No bins create one in a project</div>
)}
<div className="ctx-divider" />
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
<button className="danger" onClick={remove}><Icon name="trash" size={11} />Delete permanently</button>
</div>
);
}
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 (
<div className="asset-card" onClick={onOpen} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div className="asset-card" onClick={onOpen} onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div style={{ position: 'relative' }}>
<AssetThumb asset={asset} />
{showVideo && (

View file

@ -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;

View file

@ -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 = {};