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:
parent
9877ed351f
commit
992fbdfa20
7 changed files with 293 additions and 32 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue