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.sessionId = null;
|
||||||
this.state.processes = {};
|
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 {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId: currentSession.projectId,
|
projectId: currentSession.projectId,
|
||||||
|
|
@ -404,6 +409,8 @@ class CaptureManager {
|
||||||
startedAt: currentSession.startedAt,
|
startedAt: currentSession.startedAt,
|
||||||
stoppedAt,
|
stoppedAt,
|
||||||
duration,
|
duration,
|
||||||
|
framesReceived,
|
||||||
|
empty: framesReceived === 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,31 +113,52 @@ async function gracefulShutdown(signal) {
|
||||||
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
||||||
try {
|
try {
|
||||||
const completed = await captureManager.stop(status.sessionId);
|
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 liveAssetId = process.env.ASSET_ID || null;
|
||||||
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
|
||||||
method: 'POST',
|
// No frames received → the source never connected (bad SRT URL, dead
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
|
||||||
body: JSON.stringify({
|
// point is 0 bytes and would just clog the proxy queue with "moov
|
||||||
projectId: completed.projectId,
|
// atom not found" failures. Mark the pre-created live asset as
|
||||||
binId: completed.binId,
|
// 'error' and skip the POST /assets registration entirely.
|
||||||
clipName: completed.clipName,
|
if (completed.empty) {
|
||||||
sourceType: completed.sourceType,
|
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
|
||||||
hiresKey: completed.hiresKey,
|
if (liveAssetId) {
|
||||||
proxyKey: completed.proxyKey,
|
try {
|
||||||
needsProxy: completed.proxyKey === null,
|
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
||||||
duration: completed.duration,
|
method: 'POST',
|
||||||
capturedAt: completed.startedAt,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}),
|
});
|
||||||
});
|
} catch (e) {
|
||||||
if (!res.ok) {
|
console.error('[shutdown] failed to flag empty asset:', e.message);
|
||||||
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
}
|
||||||
} else {
|
}
|
||||||
console.log('[shutdown] asset registered with mam-api');
|
} 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) {
|
} catch (err) {
|
||||||
console.error('[shutdown] error during stop:', 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
|
// 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
|
// master but no proxy_s3_key. Used to backfill recorder-captured clips that
|
||||||
// pre-date the auto-proxy-on-finalize fix.
|
// pre-date the auto-proxy-on-finalize fix.
|
||||||
|
|
|
||||||
|
|
@ -173,3 +173,6 @@ async function loadData() {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ZAMPP_API = { fetch: apiFetch, loadData, fmtDuration, fmtSize, fmtRelative };
|
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
|
// screens-library.jsx
|
||||||
|
|
||||||
function Library({ navigate, onOpenAsset, openProject }) {
|
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 [view, setView] = React.useState('grid');
|
||||||
const [filter, setFilter] = React.useState('all');
|
const [filter, setFilter] = React.useState('all');
|
||||||
const [search, setSearch] = React.useState('');
|
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
|
let assets = openProject
|
||||||
? ALL_ASSETS.filter(function(a) { return a.project_id === openProject.id; })
|
? allAssets.filter(function(a) { return a.project_id === openProject.id; })
|
||||||
: ALL_ASSETS;
|
: allAssets;
|
||||||
|
const ALL_ASSETS = allAssets;
|
||||||
if (filter !== 'all') assets = assets.filter(function(a) { return a.status === filter; });
|
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()); });
|
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>
|
<div style={{ padding: 60, textAlign: 'center', color: 'var(--text-3)' }}>No assets match this filter.</div>
|
||||||
) : view === 'grid' ? (
|
) : view === 'grid' ? (
|
||||||
<div className="library-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>
|
||||||
) : (
|
) : (
|
||||||
<div className="library-list">
|
<div className="library-list">
|
||||||
|
|
@ -103,7 +146,7 @@ function Library({ navigate, onOpenAsset, openProject }) {
|
||||||
</div>
|
</div>
|
||||||
{assets.map(function(a) {
|
{assets.map(function(a) {
|
||||||
return (
|
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 className="thumb"><AssetThumb asset={a} /></div>
|
||||||
<div>
|
<div>
|
||||||
<div className="name">{a.name}</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.codec || '—'}</div>
|
||||||
<div className="col-sub">{a.size}</div>
|
<div className="col-sub">{a.size}</div>
|
||||||
<div className="col-sub">{a.updated}</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>
|
</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>
|
</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 [hoverStream, setHoverStream] = React.useState(null);
|
||||||
const [hovered, setHovered] = React.useState(false);
|
const [hovered, setHovered] = React.useState(false);
|
||||||
const timerRef = React.useRef(null);
|
const timerRef = React.useRef(null);
|
||||||
|
|
@ -166,7 +301,7 @@ function AssetCard({ asset, onOpen }) {
|
||||||
const showVideo = hovered && hoverStream;
|
const showVideo = hovered && hoverStream;
|
||||||
|
|
||||||
return (
|
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' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<AssetThumb asset={asset} />
|
<AssetThumb asset={asset} />
|
||||||
{showVideo && (
|
{showVideo && (
|
||||||
|
|
|
||||||
|
|
@ -564,6 +564,67 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Admin tables ========== */
|
/* ========== 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 popover menu (Users, etc.) ────────────────────────────────── */
|
||||||
.row-menu {
|
.row-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { unlink } from 'fs/promises'
|
import { stat, unlink } from 'fs/promises';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { query } from '../db/client.js';
|
import { query } from '../db/client.js';
|
||||||
|
|
@ -66,6 +66,20 @@ export const proxyWorker = async (job) => {
|
||||||
console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`);
|
console.log(`[proxy] Downloading ${inputKey} for asset ${assetId}`);
|
||||||
await downloadFromS3(S3_BUCKET, inputKey, inputPath);
|
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)
|
// Extract source metadata (fps, codec, resolution, duration, size, audio)
|
||||||
await job.updateProgress(20);
|
await job.updateProgress(20);
|
||||||
let mediaInfo = {};
|
let mediaInfo = {};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue