diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js
index 9003b3b..ae76051 100644
--- a/services/capture/src/capture-manager.js
+++ b/services/capture/src/capture-manager.js
@@ -311,7 +311,7 @@ const GROWING_PART_INTERVAL_FRAMES = 30;
// a sensible value from the recorder's configured resolution/framerate; if those
// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of
// the actual SDI raster/fps is advised before production use (see report).
-function deriveGrowingRaster(resolution, framerate) {
+function deriveGrowingRaster(resolution, framerate, scanHint = null) {
// Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'…
let fpsNum = null;
const fr = (framerate == null) ? '' : String(framerate).trim();
@@ -352,7 +352,9 @@ function deriveGrowingRaster(resolution, framerate) {
}
// Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p.
- if (scan == null) scan = (height >= 1080) ? 'i' : 'p';
+ // scanHint ('p'/'i') overrides this default so progressive Deltacast captures
+ // are wrapped as progressive MXFs.
+ if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p');
const r = rates(fpsNum);
let rawFlag;
@@ -683,7 +685,7 @@ class CaptureManager {
* Returns the argv for spawn('bash', argv).
*/
_buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?', interlaced = false }) {
- const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate);
+ const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p');
const vb = videoBitrate || GROWING_DEFAULT_BITRATE;
const ach = audioChannels ? Number(audioChannels) : 2;
diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx
index faa82e0..1b318d5 100644
--- a/services/web-ui/public/screens-library.jsx
+++ b/services/web-ui/public/screens-library.jsx
@@ -1,85 +1,863 @@
// screens-library.jsx
-// visuals.jsx - reusable visual elements
-const _thumbCache = new Map();
+function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
+ const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
+ const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
+ const BINS = bins; // legacy local name; keep so the rest of the function reads unchanged
-function AssetThumb({ asset, size = 'md' }) {
- const aspect = size === 'tall' ? '9 / 16' : '16 / 9';
- const [thumbUrl, setThumbUrl] = React.useState(_thumbCache.get(asset.id) || null);
+ // Re-fetch bins on mount + whenever the open project changes; surfaces
+ // every-project bins when the global view is on, project-scoped otherwise.
+ var refreshBins = React.useCallback(function() {
+ var qs2 = openProject ? '?project_id=' + openProject.id : '';
+ window.ZAMPP_API.fetch('/bins' + qs2)
+ .then(function(list) {
+ var normalized = (list || []).map(function(b) { return { ...b, count: b.asset_count != null ? b.asset_count : (b.count || 0), icon: b.type || 'grid' }; });
+ if (!openProject) window.ZAMPP_DATA.BINS = normalized;
+ setBins(normalized);
+ })
+ .catch(function() {});
+ }, [openProject]);
- React.useEffect(() => {
- if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return;
- let cancelled = false;
- fetch((window.ZAMPP_API_PREFIX || '/api/v1') + '/assets/' + asset.id + '/thumbnail', { credentials: 'include' })
- .then(r => r.ok ? r.json() : null)
- .then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } })
+ React.useEffect(function() {
+ refreshBins();
+ var onBinsChanged = function() { refreshBins(); };
+ window.addEventListener('df:bins-changed', onBinsChanged);
+ return function() { window.removeEventListener('df:bins-changed', onBinsChanged); };
+ }, [refreshBins]);
+
+ const createBin = () => {
+ if (!openProject) { window.alert('Open a project first (Projects → click a project), then create a bin inside it.'); return; }
+ setNewBinName(''); setCreatingBin(true);
+ };
+
+ const submitBin = (name) => {
+ if (!name || !name.trim()) { setCreatingBin(false); return; }
+ setCreatingBin(false);
+ window.ZAMPP_API.fetch('/bins', {
+ method: 'POST',
+ body: JSON.stringify({ project_id: openProject.id, name: name.trim() }),
+ })
+ .then(() => window.ZAMPP_API.fetch('/bins?project_id=' + openProject.id))
+ .then(list => setBins((list || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid' }))))
+ .catch(e => window.alert('Could not create bin: ' + e.message));
+ };
+ const [view, setView] = React.useState('grid');
+ const [filter, setFilter] = React.useState('all'); // 'all', 'ready', 'processing', 'live', 'error', 'recent'
+ const [search, setSearch] = React.useState(window._dfPendingSearch || '');
+ React.useEffect(() => { delete window._dfPendingSearch; }, []);
+ // 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 [renamingAsset, setRenamingAsset] = React.useState(null);
+ const [confirm, confirmModal] = window.useConfirm();
+ // Asset queued for hi-res download. Null means no modal showing. Set when
+ // the user clicks Download and has NOT dismissed the "are you sure" warning.
+ const [pendingDownload, setPendingDownload] = React.useState(null);
+
+ // Single entry point for hi-res downloads from the library. If the user
+ // ticked "Don't show again" in the warning modal, skip straight to the
+ // download; otherwise pop the modal with this asset queued. The modal's
+ // confirm handler calls runDownload directly.
+ const requestDownload = function(asset) {
+ if (!asset || !asset.original_s3_key) return;
+ try {
+ if (localStorage.getItem('df.lib.download.warnDismissed') === '1') {
+ runDownload(asset);
+ return;
+ }
+ } catch (_) { /* localStorage unavailable: show modal to be safe */ }
+ setPendingDownload(asset);
+ };
+ const [creatingBin, setCreatingBin] = React.useState(false);
+ const [newBinName, setNewBinName] = React.useState('');
+ const [draggingAssetId, setDraggingAssetId] = React.useState(null);
+ const [dragOverBinId, setDragOverBinId] = React.useState(null);
+ const [recentlyMovedId, setRecentlyMovedId] = React.useState(null);
+ // Rename project state
+ const [renamingProject, setRenamingProject] = React.useState(null);
+ const [projVersion, setProjVersion] = React.useState(0);
+ // Multi-select state
+ const [selectedAssets, setSelectedAssets] = React.useState(new Set());
+ const [selectionMode, setSelectionMode] = React.useState(false);
+
+ const refreshAssets = React.useCallback(() => {
+ window.ZAMPP_API.refreshAssets()
+ .then(function(normalized) {
+ setAllAssets(normalized);
+ })
.catch(() => {});
- return () => { cancelled = true; };
- }, [asset.id, asset.thumbnail_s3_key]);
+ }, []);
- if (asset.type === 'audio' || asset.media_type === 'audio') {
- return (
-
- );
- }
+ const toggleSelection = function(assetId, e) {
+ if (e) e.stopPropagation();
+ setSelectedAssets(function(prev) {
+ var next = new Set(prev);
+ if (next.has(assetId)) next.delete(assetId);
+ else next.add(assetId);
+ return next;
+ });
+ };
- if (asset.status === 'live' && asset.id) {
- if (asset.thumbnail_s3_key || thumbUrl) {
- const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Live recording thumbnail';
- return (
-
- {thumbUrl
- ?
- :
}
-
-
- );
+ const selectAll = function() {
+ setSelectedAssets(new Set(assets.map(function(a) { return a.id; })));
+ };
+
+ const clearSelection = function() {
+ setSelectedAssets(new Set());
+ setSelectionMode(false);
+ };
+
+ const bulkMoveToBin = async function(binId) {
+ var ids = Array.from(selectedAssets);
+ if (ids.length === 0) return;
+ var targetBin = bins.find(function(b) { return b.id === binId; });
+ for (var i = 0; i < ids.length; i++) {
+ var asset = allAssets.find(function(a) { return a.id === ids[i]; });
+ if (asset && targetBin && asset.project_id && targetBin.project_id && asset.project_id !== targetBin.project_id) {
+ alert('Cannot move assets to a bin in a different project.');
+ return;
+ }
}
- return ;
- }
+ try {
+ await Promise.all(ids.map(function(id) {
+ return window.ZAMPP_API.fetch('/assets/' + id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) });
+ }));
+ refreshAssets();
+ window.dispatchEvent(new Event('df:bins-changed'));
+ clearSelection();
+ } catch (e) {
+ alert('Bulk move failed: ' + e.message);
+ }
+ };
- // pending_migration: placeholder with upload icon, no video
- if (asset.status === 'pending_migration') {
- return (
-
-
-
- Awaiting migration
-
-
- );
- }
+ const bulkDelete = async function() {
+ var ids = Array.from(selectedAssets);
+ if (ids.length === 0) return;
+ if (!(await confirm({
+ title: 'Delete ' + ids.length + ' assets?',
+ message: 'Delete ' + ids.length + ' assets permanently?\nThis removes the database rows and S3 objects.\nThis cannot be undone.',
+ }))) return;
+ try {
+ await Promise.all(ids.map(function(id) {
+ return window.ZAMPP_API.fetch('/assets/' + id + '?hard=true', { method: 'DELETE' });
+ }));
+ refreshAssets();
+ clearSelection();
+ } catch (e) {
+ alert('Bulk delete failed: ' + e.message);
+ }
+ };
+
+ const deleteAsset = React.useCallback(async (asset) => {
+ if (!(await confirm({
+ title: 'Delete asset?',
+ message: '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(refreshAssets)
+ .catch(function(e) { alert('Delete failed: ' + e.message); });
+ }, [confirm, refreshAssets]);
+
+ // Auto-refresh: poll the library while it's open so live recordings flip
+ // to 'ready' (with thumbnail) without a manual reload. Also pull once on
+ // mount so uploads/imports created on other screens appear immediately.
+ const hasLive = React.useMemo(
+ () => allAssets.some(a => a.status === 'live' || a.status === 'processing' || a.status === 'ingesting'),
+ [allAssets]
+ );
+ React.useEffect(() => {
+ refreshAssets();
+ const tick = hasLive ? 4000 : 15000;
+ const id = setInterval(refreshAssets, tick);
+ const onAssetsChanged = () => refreshAssets();
+ window.addEventListener('df:assets-changed', onAssetsChanged);
+ return () => {
+ clearInterval(id);
+ window.removeEventListener('df:assets-changed', onAssetsChanged);
+ };
+ }, [hasLive, refreshAssets]);
+
+ // 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 });
+ };
+
+ // Drag-and-drop: asset → bin
+ const onAssetDragStart = function(assetId, e) {
+ e.dataTransfer.setData('text/plain', assetId);
+ e.dataTransfer.effectAllowed = 'move';
+ setDraggingAssetId(assetId);
+ };
+
+ const onBinDragOver = function(binId, e) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ if (dragOverBinId !== binId) setDragOverBinId(binId);
+ };
+
+ const onBinDragLeave = function() {
+ setDragOverBinId(null);
+ };
+
+ const onBinDrop = function(binId, e) {
+ e.preventDefault();
+ setDraggingAssetId(null);
+ var assetId = e.dataTransfer.getData('text/plain');
+ if (!assetId) return;
+ // Guard against cross-project moves
+ var asset = allAssets.find(function(a) { return a.id === assetId; });
+ var targetBin = bins.find(function(b) { return b.id === binId; });
+ if (asset && targetBin && asset.project_id && targetBin.project_id && asset.project_id !== targetBin.project_id) {
+ alert('Cannot move asset to a bin in a different project.');
+ return;
+ }
+ window.ZAMPP_API.fetch('/assets/' + assetId, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
+ .then(function() {
+ setRecentlyMovedId(assetId);
+ refreshAssets();
+ window.dispatchEvent(new Event('df:bins-changed'));
+ setTimeout(function() { setRecentlyMovedId(null); }, 2000);
+ })
+ .catch(function(e2) { alert('Move failed: ' + e2.message); });
+ };
+
+ // Project rename
+ const renameProject = function(p) { setRenamingProject(p); };
+
+ // Close drag state when drag ends anywhere
+ React.useEffect(function() {
+ if (!draggingAssetId) return;
+ var onEnd = function() { setDraggingAssetId(null); };
+ window.addEventListener('dragend', onEnd);
+ return function() { window.removeEventListener('dragend', onEnd); };
+ }, [draggingAssetId]);
+
+ // Project context menu state
+ var [projectCtx, setProjectCtx] = React.useState(null);
+ React.useEffect(function() {
+ if (!projectCtx) return;
+ var close = function() { setProjectCtx(null); };
+ // #49: add contextmenu + scroll dismiss so right-clicking another project
+ // or scrolling away doesn't leave the menu orphaned
+ window.addEventListener('click', close);
+ window.addEventListener('contextmenu', close);
+ window.addEventListener('scroll', close, true);
+ return function() {
+ window.removeEventListener('click', close);
+ window.removeEventListener('contextmenu', close);
+ window.removeEventListener('scroll', close, true);
+ };
+ }, [projectCtx]);
+
+ var openProjectCtx = function(p, e) {
+ e.preventDefault();
+ e.stopPropagation();
+ setProjectCtx({ project: p, x: e.clientX, y: e.clientY });
+ };
+
+ const [selectedBinId, setSelectedBinId] = React.useState(null);
+ // Clear bin filter on project change so a stale id doesn't hide everything.
+ React.useEffect(() => { setSelectedBinId(null); }, [openProject?.id]);
+ let assets = openProject
+ ? allAssets.filter(function(a) { return a.project_id === openProject.id; })
+ : allAssets;
+ const ALL_ASSETS = allAssets;
+ if (filter === 'recent') {
+ assets = assets.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; });
+ } else 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 (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
+
+ const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
+ const displayTitle = activeBin
+ ? (openProject ? openProject.name + ' · ' : '') + activeBin.name
+ : (openProject ? openProject.name : 'All Assets');
+ const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
+ const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
- const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return (
-
- {thumbUrl
- ?
- :
}
+
+ {confirmModal}
+
+
+
Projects
+
+
+
+ All projects
+ {ALL_ASSETS.length}
+
+ {PROJECTS.slice(0, 8).map(function(p) {
+ return (
+
+
+ {p.name}
+ {p.assets}
+
+ );
+ })}
+
+
+
+
+
Bins
+
+
+
+
+
+ {creatingBin && (
+
+
+
+ )}
+ {!creatingBin && BINS.length === 0 ? (
+
+ {openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
+
+ ) : BINS.map(function(b) {
+ const isActive = selectedBinId === b.id;
+ const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id;
+ return (
+
+
+ {b.name}
+ {b.count}
+
+ );
+ })}
+
+
+
+
Smart filters
+
+ {errorCount > 0 &&
Errors {errorCount}
}
+
Last 24h {recentCount}
+
Ready {ALL_ASSETS.filter(function(a) { return a.status === 'ready'; }).length}
+
+
+
+
+
+
+
{displayTitle}
+
· {assets.length} assets
+
+ {selectionMode && selectedAssets.size > 0 && (
+
+
{selectedAssets.size} selected
+
+
+
+
+ {BINS.length > 0 && (
+
+ Move to bin…
+ {BINS.filter(function(b) { return !openProject || b.project_id === openProject.id; }).map(function(b) {
+ return {b.name} ;
+ })}
+
+ )}
+
Delete
+
+ )}
+
+ Select
+
+
+
+
+
+
+ {['all', 'ready', 'processing', 'live', 'error'].map(function(f) {
+ return (
+
+ {f === 'all' ? 'All' : f[0].toUpperCase() + f.slice(1)}
+
+ );
+ })}
+
+
+
+
+
+
Upload
+
+
+ {assets.length === 0 ? (
+
No assets match this filter.
+ ) : view === 'grid' ? (
+
+ {assets.map(function(a) {
+ return
;
+ })}
+
+ ) : (
+
+
+ {selectionMode &&
}
+
Name
Duration
Resolution
Codec
Size
Updated
+
+ {assets.map(function(a) {
+ return (
+
+ {selectionMode && (
+
+
+
+ )}
+
+
+
{a.name}
+
+
+ {a.status}
+
+
+
{a.duration}
+
{a.res}
+
{a.codec || '·'}
+
{a.size}
+
{a.updated}
+
+
+ );
+ })}
+
+ )}
+
+ {ctxMenu && (
+
+ )}
+ {pendingDownload && (
+
+ )}
+ {renamingAsset && (
+
+ )}
+ {projectCtx && (
+
+ )}
+ {renamingProject && window.RenameProjectModal && (
+ React.createElement(window.RenameProjectModal, {
+ project: renamingProject,
+ onClose: function() { setRenamingProject(null); },
+ onSaved: function() {
+ setRenamingProject(null);
+ // Re-fetch projects and update ZAMPP_DATA so the rail refreshes
+ window.ZAMPP_API.fetch('/projects').then(function(list) {
+ if (Array.isArray(list)) {
+ window.ZAMPP_DATA.PROJECTS = list.map(function(p, i) {
+ return {
+ ...p,
+ color: (window.ZAMPP_DATA.PROJECTS.find(function(x) { return x.id === p.id; }) || {}).color
+ || window.PROJECT_COLORS?.[i % (window.PROJECT_COLORS?.length || 1)]
+ || 'var(--accent)',
+ assets: (window.ZAMPP_DATA?.ASSETS || []).filter(function(a) { return a.project_id === p.id; }).length,
+ updated: window.ZAMPP_API.fmtRelative(p.updated_at),
+ };
+ });
+ setProjVersion(function(v) { return v + 1; });
+ }
+ }).catch(function() {});
+ }
+ })
+ )}
);
}
-Both files written. Summary of changes:
+// Hi-res download trigger shared by the card and the context menu. Resolves
+// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
+// and clicks a hidden anchor so the browser does the download. The download
+// itself is direct S3 - never proxied through mam-api - so big files don't
+// touch the API container.
+function runDownload(asset) {
+ if (!asset || !asset.id) return;
+ return window.ZAMPP_API.fetch('/assets/' + asset.id + '/hires')
+ .then(function(r) {
+ if (!r || !r.url) { alert('No hi-res source available for this asset.'); return; }
+ const a = document.createElement('a');
+ a.href = r.url;
+ a.download = r.filename || ((asset.display_name || asset.name || asset.id) + '.' + (r.ext || 'mov'));
+ a.target = '_blank';
+ a.rel = 'noopener';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ })
+ .catch(function(e) { alert('Download failed: ' + (e.message || 'unknown error')); });
+}
-**screens-library.jsx**
-- `AssetCard` gains `onMigrate` prop; all `AssetCard` usages in grid pass `onMigrate={refreshAssets}`
-- `AssetCard` thumb-status block adds `pending_migration` badge (`
Pending Migration `)
-- `AssetCard` adds `.thumb-migrate-btn` button (position absolute, bottom 8px, left/right 8px) calling `POST /assets/:id/migrate-to-library`
-- List view actions column gains inline migrate button for `pending_migration` rows
-- Filter tab-group gains "Pending" tab (`pending_migration`)
-- Smart filters rail gains "Pending migration" entry (visible only when count > 0)
-- `hasLive` memo includes `pending_migration` so 4s poll activates when any such asset exists
+function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename, onDownload, onDelete }) {
+ 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]);
-**visuals.jsx**
-- `AssetThumb` handles `pending_migration` before the generic fallback: renders static placeholder with upload icon + "Awaiting migration" label, no video/HLS
-- `StatusDot` map gains `pending_migration: { color: 'var(--warning)', pulse: false }`
+ const rename = function() { if (onRename) onRename(asset); else onClose(); };
-Files at:
-- `/home/node/workspace/dragonflight/services/web-ui/public/screens-library.jsx`
-- `/home/node/workspace/dragonflight/services/web-ui/public/visuals.jsx`
+ const moveToBin = function(binId) {
+ onClose();
+ window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ bin_id: binId }) })
+ .then(function() { onChanged(); window.dispatchEvent(new Event('df:bins-changed')); })
+ .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() {
+ if (onDelete) { onDelete(asset); return; }
+ onClose();
+ };
+
+ return (
+
+
{asset.display_name || asset.name}
+
Open
+
Rename…
+ {asset.original_s3_key && onDownload && (
+
Download original…
+ )}
+
+ {(bins && bins.length > 0) ? (
+ <>
+
Move to bin
+ {bins
+ .filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
+ .slice(0, 10)
+ .map(function(b) {
+ const isCurrent = asset.bin_id === b.id;
+ return (
+
+ {b.name}{isCurrent && current }
+
+ );
+ })}
+ {asset.bin_id && (
+
+ Remove from bin
+
+ )}
+ >
+ ) : (
+
No bins: create one inside a project
+ )}
+
+
Copy asset ID
+
Delete permanently
+
+ );
+}
+
+function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, draggable, selectionMode, isSelected, onToggleSelect }) {
+ const [hoverStream, setHoverStream] = React.useState(null);
+ const [hovered, setHovered] = React.useState(false);
+ const timerRef = React.useRef(null);
+ const hlsRef = React.useRef(null);
+ const videoRef = React.useRef(null);
+
+ const handleMouseEnter = function() {
+ timerRef.current = setTimeout(function() {
+ setHovered(true);
+ if (!hoverStream) {
+ window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream')
+ .then(function(r) { if (r && r.url) setHoverStream(r); })
+ .catch(function() {});
+ }
+ }, 350);
+ };
+
+ const handleMouseLeave = function() {
+ clearTimeout(timerRef.current);
+ setHovered(false);
+ };
+
+ // HLS wiring
+ React.useEffect(function() {
+ if (!hovered || !hoverStream || hoverStream.type !== 'hls' || !videoRef.current) return;
+ if (!window.Hls) return;
+ hlsRef.current = new window.Hls({ maxBufferLength: 10 });
+ hlsRef.current.loadSource(hoverStream.url);
+ hlsRef.current.attachMedia(videoRef.current);
+ return function() {
+ if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
+ };
+ }, [hovered, hoverStream]);
+
+ const showVideo = hovered && hoverStream;
+
+ return (
+
+
+ {selectionMode && (
+
+
+
+ )}
+
+ {showVideo && (
+
+ )}
+ {/* Status badges and duration: inside the relative wrapper so
+ position:absolute is anchored to the thumbnail, not the card (#52) */}
+
+ {asset.status === 'live' && LIVE }
+ {asset.status === 'processing' && Processing }
+ {asset.status === 'error' && Error }
+
+ {/* Hi-res download trigger: only shown when the asset has an
+ original_s3_key (everything queued through ingest / conform).
+ Hidden until card hover, lives in top-right of the thumb. */}
+ {asset.original_s3_key && onDownload && (
+
+
+
+ )}
+ {(asset.type === 'video' || !asset.type) && asset.duration !== '·' &&
{asset.duration}
}
+
+
+
{asset.name}
+
+ {asset.res}
+ ·
+ {asset.size}
+
+
+
+ );
+}
+
+function ProjectContextMenu({ project, x, y, onClose, onRename }) {
+ var ref = React.useRef(null);
+ var [pos, setPos] = React.useState({ left: x, top: y });
+ React.useLayoutEffect(function() {
+ if (!ref.current) return;
+ var r = ref.current.getBoundingClientRect();
+ var margin = 8;
+ var 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]);
+
+ return (
+
+
{project.name}
+
Rename project…
+
Delete project
+
+ );
+}
+
+function binIcon(name) {
+ return { grid: 'library', live: 'record', film: 'film', proxy: 'proxy', audio: 'audio', package: 'package' }[name] || 'folder';
+}
+
+function RenameAssetModal({ asset, onClose, onSaved }) {
+ const [name, setName] = React.useState(asset.display_name || asset.name || '');
+ const [saving, setSaving] = React.useState(false);
+ const [err, setErr] = React.useState(null);
+ const original = asset.display_name || asset.name || '';
+ const submit = function() {
+ const trimmed = name.trim();
+ if (!trimmed || trimmed === original) { onClose(); return; }
+ setSaving(true); setErr(null);
+ window.ZAMPP_API.fetch('/assets/' + asset.id, { method: 'PATCH', body: JSON.stringify({ display_name: trimmed }) })
+ .then(onSaved)
+ .catch(function(e) { setSaving(false); setErr(e.message); });
+ };
+ return (
+
+
+
+
+
+ Display name
+
+
+ {err &&
{err}
}
+
+
+ Cancel
+ {saving ? 'Saving…' : 'Rename'}
+
+
+
+ );
+}
+
+// First-run-style warning before a hi-res download. Tells the operator the
+// file is full-length, may be multi-GB, and that speed depends on their
+// connection. "Don't show again on this device" persists the dismissal so
+// subsequent downloads (and every Download click after dismissal) skip
+// straight to the file. Settings → Account exposes a control to re-enable.
+function DownloadWarningModal({ asset, onClose, onConfirm }) {
+ const [dismiss, setDismiss] = React.useState(false);
+ const name = asset.display_name || asset.name || asset.id;
+ return (
+
+
+
+
Download original ingest
+
+
+
+
+ You're about to download the full-length original ingest for
+ {name} .
+
+
+ Originals are often multi-gigabyte. Download speed depends on
+ your network connection; don't start this on a metered link
+ or a busy uplink.
+
+
+
+ Don't show this again on this device
+
+
+
+ Cancel
+ Download
+
+
+
+ );
+}
+
+window.Library = Library;
+window.AssetCard = AssetCard;
diff --git a/services/web-ui/public/visuals.jsx b/services/web-ui/public/visuals.jsx
index 9e8170d..5382aa6 100644
--- a/services/web-ui/public/visuals.jsx
+++ b/services/web-ui/public/visuals.jsx
@@ -25,6 +25,10 @@ function AssetThumb({ asset, size = 'md' }) {
);
}
+ // Live/recording assets: once the capture sidecar has published a poster
+ // thumbnail (first frame of the recording), show that static frame instead
+ // of the HLS "connecting…" player. Until the poster exists (the brief window
+ // before the first segment is grabbed), fall back to the live HLS preview.
if (asset.status === 'live' && asset.id) {
if (asset.thumbnail_s3_key || thumbUrl) {
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Live recording thumbnail';
@@ -33,6 +37,7 @@ function AssetThumb({ asset, size = 'md' }) {
{thumbUrl
?
:
}
+ {/* Keep the pulsing LIVE border so it still reads as recording */}
);
@@ -40,19 +45,6 @@ function AssetThumb({ asset, size = 'md' }) {
return ;
}
- if (asset.status === 'pending_migration') {
- return (
-
-
-
- Awaiting migration
-
-
- );
- }
-
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return (
@@ -63,6 +55,10 @@ function AssetThumb({ asset, size = 'md' }) {
);
}
+// Muted inline HLS preview for a live/recording asset tile. Attaches hls.js
+// (or native HLS on Safari) to show the live feed inside the library card.
+// Shows a "connecting…" spinner while the manifest loads, falls back to a
+// placeholder with a record icon if hls.js is unavailable or playback fails.
function LiveThumb({ assetId, aspect }) {
const videoRef = React.useRef(null);
const [ready, setReady] = React.useState(false);
@@ -133,6 +129,7 @@ function LiveThumb({ assetId, aspect }) {
autoPlay
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
+ {/* Pulsing red border while connecting or playing, matches LIVE badge colour */}
{!ready && !failed && (
;
@@ -254,6 +250,19 @@ function Elapsed({ seconds, live = false }) {
return
{String(h).padStart(2,'0')}:{String(m).padStart(2,'0')}:{String(s).padStart(2,'0')} ;
}
+// ─────────────────────────────────────────────────────────────────────────
+// ConfirmModal + useConfirm — in-page replacement for window.confirm().
+//
+// Usage in a component:
+// const [confirm, confirmModal] = useConfirm();
+// ...
+// if (!(await confirm({ title: 'Delete user?', message: '…' }))) return;
+// ...
+// return (<>{confirmModal} ...rest of UI... >);
+//
+// confirm(opts) returns a Promise
. Options:
+// title, message, confirmLabel (default 'Delete'), cancelLabel ('Cancel'),
+// danger (default true → red confirm button).
function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = 'Cancel', danger = true, onConfirm, onCancel }) {
React.useEffect(() => {
const onKey = (e) => {
@@ -274,7 +283,7 @@ function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = '
{typeof message === 'string'
? message.split('\n').map((line, i) => (
-
{line || ' '}
+
{line || ' '}
))
:
{message}
}
@@ -288,7 +297,7 @@ function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = '
}
function useConfirm() {
- const [state, setState] = React.useState(null);
+ const [state, setState] = React.useState(null); // { opts, resolve } | null
const confirm = React.useCallback((opts) => {
return new Promise((resolve) => {