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:
parent
b3c61134fc
commit
4f98f2b773
1 changed files with 177 additions and 10 deletions
|
|
@ -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={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
||||
Version history not yet available.
|
||||
<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: '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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue