feat(asset): filmstrip right-click menu + Files tab

Filmstrip:
- Right-click on the filmstrip opens a context menu with
  'Re-generate filmstrip' and 'Re-generate proxy'
- filmstripKey state forces the build effect to re-run on demand
  without waiting for a streamUrl/totalMs change
- Context menu dismisses on click, contextmenu, and scroll

Files tab (replaces empty Versions tab):
- Proxy: status badge, S3 key path, inline video preview, re-generate button
- Hi-res master: status badge and S3 key path
- Thumbnail: status badge, S3 key path, inline thumbnail image, re-generate button
- Filmstrip: status badge, frame count, scrollable strip of first 14 frames,
  re-generate button (disabled while building)
This commit is contained in:
Zac Gaetano 2026-05-26 16:07:33 +00:00
parent b3c61134fc
commit 4f98f2b773

View file

@ -37,6 +37,7 @@ function AssetDetail({ asset, onClose }) {
const [reprocessing, setReprocessing] = React.useState(null); // 'proxy' | 'thumbnail' | null
const [filmFrames, setFilmFrames] = React.useState([]);
const [filmstripLoading, setFilmstripLoading] = React.useState(false);
const [filmstripKey, setFilmstripKey] = React.useState(0);
const videoRef = React.useRef(null);
const assetId = asset && asset.id;
@ -164,7 +165,7 @@ function AssetDetail({ asset, onClose }) {
};
build();
return function() { cancelled = true; };
}, [streamUrl, streamType, totalMs]);
}, [streamUrl, streamType, totalMs, filmstripKey]);
// Fake playback timer only used when no real video stream
React.useEffect(() => {
@ -491,7 +492,18 @@ function AssetDetail({ asset, onClose }) {
)}
{totalMs > 0 && (
<FilmStrip seed={asset.seed || 1} current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} frames={filmFrames} loading={filmstripLoading} />
<FilmStrip
seed={asset.seed || 1}
current={currentMs}
total={totalMs}
onSeek={seek}
comments={visibleComments}
frames={filmFrames}
loading={filmstripLoading}
onRegenFilmstrip={function() { setFilmFrames([]); setFilmstripKey(function(k) { return k + 1; }); }}
onRegenProxy={function() { reprocessJob('proxy'); }}
reprocessing={reprocessing}
/>
)}
</div>
@ -499,8 +511,8 @@ function AssetDetail({ asset, onClose }) {
<button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
Comments <span className="count">{comments.length}</span>
</button>
<button className={tab === "versions" ? "active" : ""} onClick={function() { setTab("versions"); }}>
Versions
<button className={tab === "files" ? "active" : ""} onClick={function() { setTab("files"); }}>
Files
</button>
<button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
Metadata
@ -535,7 +547,18 @@ function AssetDetail({ asset, onClose }) {
/>
)
)}
{tab === "versions" && <VersionsTab />}
{tab === "files" && (
<FilesTab
asset={asset}
filmFrames={filmFrames}
filmstripLoading={filmstripLoading}
streamUrl={streamUrl}
reprocessing={reprocessing}
onRegenProxy={function() { reprocessJob('proxy'); }}
onRegenThumbnail={function() { reprocessJob('thumbnail'); }}
onRegenFilmstrip={function() { setFilmFrames([]); setFilmstripKey(function(k) { return k + 1; }); }}
/>
)}
{tab === "metadata" && <MetadataTab asset={asset} />}
{tab === "audio" && <AudioTab asset={asset} />}
</div>
@ -586,19 +609,40 @@ function PlaybackBar({ current, total, onSeek, comments }) {
);
}
function FilmStrip({ seed, current, total, onSeek, comments, frames, loading }) {
function FilmStrip({ seed, current, total, onSeek, comments, frames, loading, onRegenFilmstrip, onRegenProxy, reprocessing }) {
const ref = React.useRef(null);
const [ctx, setCtx] = React.useState(null);
const fallbackFrames = 28;
const handle = function(e) {
if (!ref.current || total <= 0) return;
const r = ref.current.getBoundingClientRect();
onSeek(((e.clientX - r.left) / r.width) * total);
};
const handleContextMenu = function(e) {
e.preventDefault();
setCtx({ x: e.clientX, y: e.clientY });
};
React.useEffect(function() {
if (!ctx) return;
var close = function() { setCtx(null); };
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);
};
}, [ctx]);
const pct = total > 0 ? (current / total) * 100 : 0;
const items = Array.isArray(frames) && frames.length ? frames : Array.from({ length: fallbackFrames }).map(function(_, i) { return null; });
return (
<div className="filmstrip-wrap">
<div className="filmstrip" ref={ref} onClick={handle}>
<div className="filmstrip" ref={ref} onClick={handle} onContextMenu={handleContextMenu}>
{items.map(function(src, i) {
return (
<div key={i} className="film-frame" style={src ? undefined : { background: _FRAME_GRADIENTS[(seed + i) % _FRAME_GRADIENTS.length] }}>
@ -628,6 +672,17 @@ function FilmStrip({ seed, current, total, onSeek, comments, frames, loading })
})}
</div>
{loading && <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Building filmstrip</div>}
{ctx && (
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={function(e) { e.stopPropagation(); }}>
<button onClick={function() { setCtx(null); onRegenFilmstrip && onRegenFilmstrip(); }}>
<Icon name="jobs" size={11} />Re-generate filmstrip
</button>
<button onClick={function() { setCtx(null); onRegenProxy && onRegenProxy(); }} disabled={!!reprocessing}>
<Icon name="jobs" size={11} />{reprocessing === 'proxy' ? 'Queuing…' : 'Re-generate proxy'}
</button>
</div>
)}
</div>
);
}
@ -696,10 +751,122 @@ function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
);
}
function VersionsTab() {
function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing, onRegenProxy, onRegenThumbnail, onRegenFilmstrip }) {
const hasProxy = !!asset.proxy_s3_key;
const hasHires = !!asset.original_s3_key;
const hasThumb = !!asset.thumbnail_s3_key;
const hasFilmstrip = Array.isArray(filmFrames) && filmFrames.length > 0;
// Rows: label | status badge | path | action button
const FileRow = function({ label, present, path, icon, actionLabel, onAction, disabled, children }) {
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, background: 'var(--bg-2)', overflow: 'hidden', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderBottom: children ? '1px solid var(--border)' : 'none' }}>
<Icon name={icon || 'jobs'} size={13} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}>
<span style={{ fontWeight: 600, fontSize: 13 }}>{label}</span>
{present
? <span className="badge success">ready</span>
: <span className="badge neutral">missing</span>}
</div>
{path && (
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={path}>
{path}
</div>
)}
</div>
{actionLabel && onAction && (
<button className="btn ghost sm" onClick={onAction} disabled={!!disabled}>
{disabled ? 'Queuing…' : actionLabel}
</button>
)}
</div>
{children && <div style={{ padding: '10px 12px' }}>{children}</div>}
</div>
);
};
return (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
Version history not yet available.
<div style={{ padding: '12px 0' }}>
{/* Proxy */}
<FileRow
label="Proxy (browser playback)"
present={hasProxy}
path={asset.proxy_s3_key || null}
icon="play"
actionLabel={reprocessing === 'proxy' ? 'Queuing…' : 'Re-generate'}
onAction={onRegenProxy}
disabled={reprocessing === 'proxy'}
>
{streamUrl && (
<video
src={streamUrl}
style={{ width: '100%', maxHeight: 160, objectFit: 'contain', borderRadius: 4, background: '#000', display: 'block' }}
muted
controls
/>
)}
{!streamUrl && !hasProxy && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>No browser-playable proxy yet.</div>
)}
</FileRow>
{/* Hi-res */}
<FileRow
label="Hi-res master"
present={hasHires}
path={asset.original_s3_key || null}
icon="download"
actionLabel={null}
onAction={null}
/>
{/* Thumbnail */}
<FileRow
label="Thumbnail"
present={hasThumb}
path={asset.thumbnail_s3_key || null}
icon="library"
actionLabel={reprocessing === 'thumbnail' ? 'Queuing…' : 'Re-generate'}
onAction={onRegenThumbnail}
disabled={reprocessing === 'thumbnail'}
>
{hasThumb && (
<img
src={'/api/v1/assets/' + asset.id + '/thumbnail'}
alt="Thumbnail"
style={{ maxHeight: 100, borderRadius: 4, display: 'block' }}
onError={function(e) { e.target.style.display = 'none'; }}
/>
)}
</FileRow>
{/* Filmstrip */}
<FileRow
label="Filmstrip"
present={hasFilmstrip}
path={hasFilmstrip ? filmFrames.length + ' frames captured' : filmstripLoading ? 'Building…' : 'Not built yet'}
icon="editor"
actionLabel="Re-generate"
onAction={onRegenFilmstrip}
disabled={filmstripLoading}
>
{hasFilmstrip && (
<div style={{ display: 'flex', gap: 2, overflowX: 'auto', paddingBottom: 2 }}>
{filmFrames.filter(Boolean).slice(0, 14).map(function(src, i) {
return (
<img key={i} src={src} alt="" style={{ width: 80, height: 45, objectFit: 'cover', borderRadius: 3, flexShrink: 0 }} />
);
})}
</div>
)}
{filmstripLoading && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Building filmstrip from proxy</div>
)}
</FileRow>
</div>
);
}