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)
934 lines
38 KiB
JavaScript
934 lines
38 KiB
JavaScript
// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)
|
|
|
|
// Simple gradient palette — replaces the missing thumbGrad function
|
|
const _FRAME_GRADIENTS = [
|
|
'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
|
|
'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
|
|
'linear-gradient(135deg,#0d1520 0%,#1a2535 100%)',
|
|
'linear-gradient(135deg,#1f1a2e 0%,#2a2040 100%)',
|
|
'linear-gradient(135deg,#0f1e18 0%,#1a3028 100%)',
|
|
'linear-gradient(135deg,#1e1510 0%,#302018 100%)',
|
|
'linear-gradient(135deg,#1a1020 0%,#281830 100%)',
|
|
'linear-gradient(135deg,#101828 0%,#182438 100%)',
|
|
'linear-gradient(135deg,#1e2820 0%,#283830 100%)',
|
|
'linear-gradient(135deg,#201820 0%,#302030 100%)',
|
|
'linear-gradient(135deg,#181e28 0%,#202838 100%)',
|
|
];
|
|
|
|
function AssetDetail({ asset, onClose }) {
|
|
const [playing, setPlaying] = React.useState(false);
|
|
const [currentMs, setCurrentMs] = React.useState(0);
|
|
const [tab, setTab] = React.useState("comments");
|
|
const [showResolved, setShowResolved] = React.useState(false);
|
|
const [comments, setComments] = React.useState([]);
|
|
const [newComment, setNewComment] = React.useState("");
|
|
const [commentsLoading, setCommentsLoading] = React.useState(false);
|
|
|
|
// Stream / video state
|
|
const [streamUrl, setStreamUrl] = React.useState(null);
|
|
const [streamType, setStreamType] = React.useState(null);
|
|
// Why the stream is unavailable: 'no_proxy' (has hi-res source, browser
|
|
// can't play it directly) or null (still loading / live / playable).
|
|
const [streamReason, setStreamReason] = React.useState(null);
|
|
const [streamHasSource, setStreamHasSource] = React.useState(false);
|
|
const [streamLoading, setStreamLoading] = React.useState(false);
|
|
const [videoDuration, setVideoDuration] = React.useState(0);
|
|
const [retrying, setRetrying] = React.useState(false);
|
|
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;
|
|
const totalMs = videoDuration > 0 ? videoDuration : parseDuration(asset.duration);
|
|
|
|
// Fetch stream URL when asset changes
|
|
React.useEffect(() => {
|
|
if (!assetId) return;
|
|
setStreamUrl(null);
|
|
setStreamType(null);
|
|
setStreamReason(null);
|
|
setStreamHasSource(false);
|
|
setVideoDuration(0);
|
|
setCurrentMs(0);
|
|
setPlaying(false);
|
|
setFilmFrames([]);
|
|
setStreamLoading(true);
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/stream')
|
|
.then(function(r) {
|
|
if (r && r.url) {
|
|
setStreamUrl(r.url);
|
|
setStreamType(r.type || 'mp4');
|
|
} else if (r) {
|
|
// {url: null, reason: 'no_proxy', has_source: true|false}
|
|
setStreamReason(r.reason || null);
|
|
setStreamHasSource(!!r.has_source);
|
|
}
|
|
})
|
|
.catch(function() {})
|
|
.finally(function() { setStreamLoading(false); });
|
|
}, [assetId]);
|
|
|
|
// Wire hls.js for live HLS streams
|
|
React.useEffect(() => {
|
|
if (!streamUrl || streamType !== 'hls' || !videoRef.current) return;
|
|
if (!window.Hls) return;
|
|
const hls = new window.Hls();
|
|
hls.loadSource(streamUrl);
|
|
hls.attachMedia(videoRef.current);
|
|
return function() { hls.destroy(); };
|
|
}, [streamUrl, streamType]);
|
|
|
|
// Build filmstrip from real video frames. HLS streams use hls.js probe.
|
|
React.useEffect(() => {
|
|
if (!streamUrl || totalMs <= 0) {
|
|
setFilmFrames([]);
|
|
setFilmstripLoading(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const build = async function() {
|
|
setFilmstripLoading(true);
|
|
const probe = document.createElement('video');
|
|
// Do NOT set crossOrigin — the /video endpoint is same-origin and requires
|
|
// session cookies. crossOrigin='anonymous' strips credentials → 401 → load
|
|
// fails → filmstrip never builds. Same-origin video can be drawn to canvas
|
|
// without crossOrigin (no taint applies).
|
|
probe.muted = true;
|
|
probe.playsInline = true;
|
|
probe.preload = 'auto';
|
|
probe.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;pointer-events:none';
|
|
document.body.appendChild(probe);
|
|
const timeout = setTimeout(function() {
|
|
if (probe.parentNode) probe.parentNode.removeChild(probe);
|
|
if (!cancelled) { setFilmFrames([]); setFilmstripLoading(false); }
|
|
cancelled = true;
|
|
}, 15000);
|
|
try {
|
|
if (streamType === 'hls') {
|
|
if (!window.Hls) throw new Error('hls.js not loaded');
|
|
await new Promise(function(resolve, reject) {
|
|
const hls = new window.Hls();
|
|
hls.on(window.Hls.Events.MANIFEST_PARSED, function() {
|
|
probe.oncanplay = function() { probe.oncanplay = null; resolve(); };
|
|
probe.onerror = reject;
|
|
});
|
|
hls.on(window.Hls.Events.ERROR, function(ev, data) { reject(data); });
|
|
hls.loadSource(streamUrl);
|
|
hls.attachMedia(probe);
|
|
});
|
|
} else {
|
|
await new Promise(function(resolve, reject) {
|
|
probe.onloadedmetadata = resolve;
|
|
probe.onerror = reject;
|
|
probe.src = streamUrl;
|
|
});
|
|
}
|
|
const frameCount = 28;
|
|
const width = 160;
|
|
const height = 90;
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
const nextFrames = [];
|
|
for (let i = 0; i < frameCount; i++) {
|
|
const at = frameCount === 1 ? 0 : (probe.duration * i) / (frameCount - 1);
|
|
await new Promise(function(resolve) {
|
|
const done = function() {
|
|
try {
|
|
ctx.drawImage(probe, 0, 0, width, height);
|
|
nextFrames.push(canvas.toDataURL('image/jpeg', 0.72));
|
|
} catch (_) {
|
|
nextFrames.push(null);
|
|
}
|
|
resolve();
|
|
};
|
|
const onSeeked = function() {
|
|
probe.removeEventListener('seeked', onSeeked);
|
|
done();
|
|
};
|
|
probe.addEventListener('seeked', onSeeked);
|
|
probe.currentTime = Math.min(Math.max(at, 0), Math.max(0, probe.duration - 0.05));
|
|
});
|
|
if (cancelled) return;
|
|
}
|
|
if (!cancelled) setFilmFrames(nextFrames);
|
|
} catch (_) {
|
|
if (!cancelled) setFilmFrames([]);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (probe.parentNode) probe.parentNode.removeChild(probe);
|
|
if (!cancelled) setFilmstripLoading(false);
|
|
}
|
|
};
|
|
build();
|
|
return function() { cancelled = true; };
|
|
}, [streamUrl, streamType, totalMs, filmstripKey]);
|
|
|
|
// Fake playback timer — only used when no real video stream
|
|
React.useEffect(() => {
|
|
if (!playing || totalMs <= 0 || streamUrl) return;
|
|
const i = setInterval(function() {
|
|
setCurrentMs(function(t) {
|
|
const next = t + 100;
|
|
if (next >= totalMs) { setPlaying(false); return totalMs; }
|
|
return next;
|
|
});
|
|
}, 100);
|
|
return function() { clearInterval(i); };
|
|
}, [playing, totalMs, streamUrl]);
|
|
|
|
const togglePlay = function() {
|
|
if (videoRef.current) {
|
|
if (videoRef.current.paused) { videoRef.current.play(); }
|
|
else { videoRef.current.pause(); }
|
|
} else {
|
|
setPlaying(function(p) { return !p; });
|
|
}
|
|
};
|
|
|
|
const seek = function(ms) {
|
|
const clamped = Math.max(0, Math.min(totalMs || 0, ms));
|
|
setCurrentMs(clamped);
|
|
if (videoRef.current) videoRef.current.currentTime = clamped / 1000;
|
|
};
|
|
|
|
// Pull a presigned hi-res URL and trigger a browser download with the
|
|
// asset's display name as the filename. Falls back to opening in a new tab.
|
|
const [downloading, setDownloading] = React.useState(false);
|
|
const downloadHires = function() {
|
|
if (downloading) return;
|
|
setDownloading(true);
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/hires')
|
|
.then(function(r) {
|
|
if (!r || !r.url) { window.alert('No hi-res source available for this asset.'); return; }
|
|
const a = document.createElement('a');
|
|
a.href = r.url;
|
|
a.download = r.filename || (asset.name + '.' + (r.ext || 'mov'));
|
|
a.target = '_blank';
|
|
a.rel = 'noopener';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
})
|
|
.catch(function(e) { window.alert('Download failed: ' + (e.message || 'unknown error')); })
|
|
.finally(function() { setDownloading(false); });
|
|
};
|
|
|
|
// Right-click style menu on the kebab icon — delete, copy ID.
|
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
|
const moreBtnRef = React.useRef(null);
|
|
React.useEffect(function() {
|
|
if (!menuOpen) return;
|
|
const close = function() { setMenuOpen(false); };
|
|
window.addEventListener('click', close);
|
|
return function() { window.removeEventListener('click', close); };
|
|
}, [menuOpen]);
|
|
|
|
const copyId = function() {
|
|
if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {});
|
|
setMenuOpen(false);
|
|
};
|
|
const deleteAsset = function() {
|
|
setMenuOpen(false);
|
|
if (!window.confirm('Delete "' + (asset.name || assetId) + '" permanently?\nRemoves DB row + S3 objects. Cannot be undone.')) return;
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
|
|
.then(function() { onClose && onClose(); })
|
|
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
|
};
|
|
|
|
const retryProcessing = function() {
|
|
if (retrying) return;
|
|
setRetrying(true);
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
|
|
.then(function() {
|
|
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
|
|
})
|
|
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
|
|
.finally(function() { setRetrying(false); });
|
|
};
|
|
|
|
const reprocessJob = function(type) {
|
|
if (reprocessing) return;
|
|
setReprocessing(type);
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=' + type, { method: 'POST' })
|
|
.then(function() {
|
|
window.alert((type === 'proxy' ? 'Proxy' : 'Thumbnail') + ' job queued. Refresh in a moment to see the result.');
|
|
})
|
|
.catch(function(e) { window.alert('Reprocess failed: ' + (e.message || 'unknown error')); })
|
|
.finally(function() { setReprocessing(null); });
|
|
};
|
|
|
|
// Map a /assets/:id/comments row into the legacy shape the consumer
|
|
// components (PlaybackBar pins, FilmStrip pins, comment list) already expect.
|
|
function _normalizeComment(row) {
|
|
const frameMs = row.frame_ms != null ? row.frame_ms : 0;
|
|
const real = row.created_at
|
|
? window.ZAMPP_API.fmtRelative(row.created_at)
|
|
: 'just now';
|
|
return {
|
|
id: row.id,
|
|
who: row.author_name || window.ZAMPP_DATA?.ME?.name || 'You',
|
|
avatar: row.author_initials || (row.author_name ? row.author_name.slice(0, 2).toUpperCase() : (window.ZAMPP_DATA?.ME?.initials || 'ZG')),
|
|
time: msToTimecode(frameMs),
|
|
frame_ms: frameMs,
|
|
real,
|
|
text: row.body,
|
|
resolved: !!row.resolved,
|
|
frame: Math.floor(frameMs / 1000 * 30),
|
|
};
|
|
}
|
|
|
|
// Load persisted comments whenever the open asset changes.
|
|
React.useEffect(() => {
|
|
if (!assetId) return;
|
|
setCommentsLoading(true);
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments')
|
|
.then(function(r) { setComments(((r && r.comments) || []).map(_normalizeComment)); })
|
|
.catch(function() { setComments([]); })
|
|
.finally(function() { setCommentsLoading(false); });
|
|
}, [assetId]);
|
|
|
|
const addComment = function() {
|
|
const text = newComment.trim();
|
|
if (!text) return;
|
|
setNewComment('');
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ body: text, frame_ms: Math.round(currentMs) }),
|
|
})
|
|
.then(function(row) {
|
|
setComments(function(c) { return [...c, _normalizeComment(row)]; });
|
|
})
|
|
.catch(function(e) {
|
|
window.alert('Could not post comment: ' + (e.message || 'unknown error'));
|
|
setNewComment(text);
|
|
});
|
|
};
|
|
|
|
const toggleResolved = function(c) {
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ resolved: !c.resolved }),
|
|
})
|
|
.then(function(row) {
|
|
setComments(function(prev) { return prev.map(x => x.id === c.id ? _normalizeComment(row) : x); });
|
|
})
|
|
.catch(function() {});
|
|
};
|
|
|
|
const deleteComment = function(c) {
|
|
if (!confirm('Delete this comment?')) return;
|
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' })
|
|
.then(function() {
|
|
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
|
|
})
|
|
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
|
};
|
|
|
|
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
|
|
|
|
// The player overlay text reflects three states: live processing,
|
|
// explicit error, "ingested but never proxied" (the SRT-archive case),
|
|
// or generic not-ready.
|
|
const isMissingProxy = !streamUrl && streamReason === 'no_proxy' && streamHasSource;
|
|
const statusMessage =
|
|
asset.status === 'processing' ? 'Processing…' :
|
|
asset.status === 'live' ? 'Live recording in progress' :
|
|
asset.status === 'error' ? 'Processing failed' :
|
|
isMissingProxy ? 'This clip was ingested but never got a browser-playable proxy.' :
|
|
'Preview not yet available';
|
|
|
|
// "Retry processing" (for status=error) and "Generate proxy" (for
|
|
// status=ready/archived but missing proxy) both call the same /retry
|
|
// endpoint. Label depends on what the user is staring at.
|
|
const canRetry = (asset.status === 'error') || isMissingProxy;
|
|
const retryLabel = isMissingProxy ? 'Generate proxy' : 'Retry processing';
|
|
|
|
return (
|
|
<div className="asset-detail fade-in">
|
|
<div className="asset-detail-header">
|
|
<button className="icon-btn" onClick={onClose}><Icon name="arrowLeft" /></button>
|
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span style={{ fontWeight: 600, fontSize: 14 }}>{asset.name}</span>
|
|
<StatusDot status={asset.status} />
|
|
</div>
|
|
<div style={{ fontSize: 11.5, color: "var(--text-3)", display: "flex", gap: 6 }}>
|
|
<span>{asset.project}</span><span>·</span><span>updated {asset.updated}</span>
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1 }} />
|
|
<button className="btn ghost sm" onClick={downloadHires} disabled={downloading} title="Download the hi-res master file">
|
|
<Icon name="download" />{downloading ? 'Preparing…' : 'Download'}
|
|
</button>
|
|
<div style={{ position: 'relative' }}>
|
|
<button ref={moreBtnRef} className="icon-btn" onClick={function(e) { e.stopPropagation(); setMenuOpen(function(v) { return !v; }); }}>
|
|
<Icon name="more" />
|
|
</button>
|
|
{menuOpen && (
|
|
<div className="row-menu" style={{ right: 0, left: 'auto' }} onClick={function(e) { e.stopPropagation(); }}>
|
|
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
|
|
<button onClick={function() { setMenuOpen(false); reprocessJob('proxy'); }} disabled={!!reprocessing}>
|
|
<Icon name="jobs" size={11} />{reprocessing === 'proxy' ? 'Queuing…' : 'Re-generate proxy'}
|
|
</button>
|
|
<button onClick={function() { setMenuOpen(false); reprocessJob('thumbnail'); }} disabled={!!reprocessing}>
|
|
<Icon name="jobs" size={11} />{reprocessing === 'thumbnail' ? 'Queuing…' : 'Re-generate thumbnail'}
|
|
</button>
|
|
<button className="danger" onClick={deleteAsset}><Icon name="trash" size={11} />Delete permanently</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="asset-detail-body">
|
|
<div className="player-col">
|
|
<div className="player">
|
|
<div className="player-canvas">
|
|
{streamUrl ? (
|
|
<video
|
|
key={streamUrl}
|
|
ref={videoRef}
|
|
src={streamType !== 'hls' ? streamUrl : undefined}
|
|
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', background: '#000' }}
|
|
onTimeUpdate={function() { if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); }}
|
|
onLoadedMetadata={function() { if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); }}
|
|
onPlay={function() { setPlaying(true); }}
|
|
onPause={function() { setPlaying(false); }}
|
|
onEnded={function() { setPlaying(false); }}
|
|
/>
|
|
) : (
|
|
<React.Fragment>
|
|
<FauxFrame seed={asset.seed || 1} />
|
|
<div className="scanlines" />
|
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div style={{ textAlign: 'center', color: 'var(--text-3)', maxWidth: 360, padding: '0 16px' }}>
|
|
{streamLoading ? (
|
|
<div style={{ fontSize: 13 }}>Loading…</div>
|
|
) : (
|
|
<React.Fragment>
|
|
<div style={{ fontSize: 13, marginBottom: 8 }}>{statusMessage}</div>
|
|
<StatusDot status={asset.status} />
|
|
{canRetry && (
|
|
<button
|
|
className="btn primary sm"
|
|
style={{ marginTop: 14, display: 'inline-flex' }}
|
|
onClick={retryProcessing}
|
|
disabled={retrying}
|
|
>
|
|
{retrying ? 'Queueing…' : retryLabel}
|
|
</button>
|
|
)}
|
|
{isMissingProxy && (
|
|
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 10 }}>
|
|
The hi-res master is in storage. We just need to transcode a browser-playable copy.
|
|
</div>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!streamLoading && !playing && totalMs > 0 && (
|
|
<button className="player-play-overlay" onClick={togglePlay}>
|
|
<Icon name="play" size={28} />
|
|
</button>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
|
|
<div className="player-overlay-markers">
|
|
{visibleComments
|
|
.filter(function(c) { return Math.abs(parseDuration(c.time) - currentMs) < 200; })
|
|
.map(function(c) {
|
|
return (
|
|
<div key={c.id} className="player-pin">
|
|
<div className="player-pin-avatar">{c.avatar}</div>
|
|
<div className="player-pin-bubble">{c.text}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{asset.status === "live" && (
|
|
<div style={{ position: "absolute", top: 12, left: 12 }}>
|
|
<span className="badge live">LIVE · REC</span>
|
|
</div>
|
|
)}
|
|
{totalMs > 0 && (
|
|
<div className="player-tc">
|
|
<span className="mono">{msToTimecode(currentMs)}</span>
|
|
<span style={{ opacity: 0.5 }}> / {asset.duration}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{totalMs > 0 && (
|
|
<div className="player-controls">
|
|
<button className="icon-btn" onClick={togglePlay}>
|
|
<Icon name={playing ? "pause" : "play"} size={14} />
|
|
</button>
|
|
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
|
|
<PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
|
|
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 70, textAlign: "right" }}>{asset.duration}</span>
|
|
{streamUrl && (
|
|
<React.Fragment>
|
|
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
|
|
<button
|
|
className="icon-btn"
|
|
title="Toggle mute"
|
|
onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
|
|
<Icon name="audio" size={14} />
|
|
</button>
|
|
<button
|
|
className="icon-btn"
|
|
title="Toggle fullscreen"
|
|
onClick={function() { if (videoRef.current && videoRef.current.requestFullscreen) videoRef.current.requestFullscreen().catch(function() {}); }}>
|
|
<Icon name="layout" size={14} />
|
|
</button>
|
|
</React.Fragment>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{totalMs > 0 && (
|
|
<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>
|
|
|
|
<div className="asset-detail-tabs">
|
|
<button className={tab === "comments" ? "active" : ""} onClick={function() { setTab("comments"); }}>
|
|
Comments <span className="count">{comments.length}</span>
|
|
</button>
|
|
<button className={tab === "files" ? "active" : ""} onClick={function() { setTab("files"); }}>
|
|
Files
|
|
</button>
|
|
<button className={tab === "metadata" ? "active" : ""} onClick={function() { setTab("metadata"); }}>
|
|
Metadata
|
|
</button>
|
|
<button className={tab === "audio" ? "active" : ""} onClick={function() { setTab("audio"); }}>
|
|
Audio
|
|
</button>
|
|
<div style={{ flex: 1 }} />
|
|
{tab === "comments" && (
|
|
<label className="tiny-toggle">
|
|
<input type="checkbox" checked={showResolved} onChange={function(e) { setShowResolved(e.target.checked); }} /> Show resolved
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
<div className="asset-detail-content">
|
|
{tab === "comments" && (
|
|
commentsLoading ? (
|
|
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>Loading comments…</div>
|
|
) : (
|
|
<CommentsList
|
|
comments={visibleComments}
|
|
onSeek={function(c) { seek(c.frame_ms != null ? c.frame_ms : parseDuration(c.time)); }}
|
|
onResolve={function(id) {
|
|
const c = comments.find(x => x.id === id);
|
|
if (c) toggleResolved(c);
|
|
}}
|
|
onDelete={function(id) {
|
|
const c = comments.find(x => x.id === id);
|
|
if (c) deleteComment(c);
|
|
}}
|
|
/>
|
|
)
|
|
)}
|
|
{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>
|
|
</div>
|
|
|
|
<div className="comment-composer-col">
|
|
<CommentComposer
|
|
asset={asset}
|
|
currentMs={currentMs}
|
|
value={newComment}
|
|
onChange={setNewComment}
|
|
onSubmit={addComment}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlaybackBar({ current, total, onSeek, comments }) {
|
|
const ref = React.useRef(null);
|
|
const handle = function(e) {
|
|
const r = ref.current.getBoundingClientRect();
|
|
const p = (e.clientX - r.left) / r.width;
|
|
onSeek(Math.max(0, Math.min(1, p)) * total);
|
|
};
|
|
const pct = total > 0 ? (current / total) * 100 : 0;
|
|
return (
|
|
<div className="playback-bar" ref={ref} onClick={handle}>
|
|
<div className="playback-fill" style={{ width: pct + '%' }} />
|
|
<div className="playback-handle" style={{ left: pct + '%' }} />
|
|
{comments.map(function(c) {
|
|
const ct = parseDuration(c.time);
|
|
if (!ct || total <= 0) return null;
|
|
const x = (ct / total) * 100;
|
|
return (
|
|
<div
|
|
key={c.id}
|
|
className={'playback-marker ' + (c.resolved ? 'resolved' : '')}
|
|
style={{ left: x + '%' }}
|
|
onClick={function(e) { e.stopPropagation(); onSeek(ct); }}
|
|
>
|
|
<span className="marker-avatar">{c.avatar}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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} 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] }}>
|
|
{src ? (
|
|
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
) : (
|
|
<FauxFrame seed={(seed + i) % 6} />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="filmstrip-playhead" style={{ left: pct + '%' }} />
|
|
{comments.map(function(c) {
|
|
const ct = parseDuration(c.time);
|
|
if (!ct || total <= 0) return null;
|
|
const x = (ct / total) * 100;
|
|
return (
|
|
<div key={c.id} className={'filmstrip-pin ' + (c.resolved ? 'resolved' : '')} style={{ left: x + '%' }}>
|
|
<span className="filmstrip-pin-avatar">{c.avatar}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="filmstrip-tc">
|
|
{[0, 0.25, 0.5, 0.75, 1].map(function(p) {
|
|
return <span key={p} className="mono">{msToTimecode(p * total)}</span>;
|
|
})}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function CommentsList({ comments, onSeek, onResolve, onDelete }) {
|
|
if (comments.length === 0) {
|
|
return <div style={{ padding: 40, textAlign: "center", color: "var(--text-3)" }}>No comments yet. Add one below to mark a frame.</div>;
|
|
}
|
|
return (
|
|
<div className="comments-list">
|
|
{comments.map(function(c) {
|
|
return (
|
|
<div key={c.id} className={'comment ' + (c.resolved ? 'resolved' : '')}>
|
|
<div className="comment-avatar avatar" style={{ background: avatarColor(c.avatar) }}>{c.avatar}</div>
|
|
<div className="comment-body">
|
|
<div className="comment-head">
|
|
<span className="comment-who">{c.who}</span>
|
|
<button className="comment-time" onClick={function() { onSeek(c); }} title="Jump to this frame"><Icon name="clock" size={10} />{c.time}</button>
|
|
<span className="comment-when">{c.real}</span>
|
|
<div style={{ flex: 1 }} />
|
|
<button className="comment-action" onClick={function() { onResolve(c.id); }}
|
|
title={c.resolved ? 'Reopen' : 'Mark resolved'}>
|
|
<Icon name={c.resolved ? "refresh" : "check"} size={12} />
|
|
</button>
|
|
{onDelete && (
|
|
<button className="comment-action" onClick={function() { onDelete(c.id); }} title="Delete comment">
|
|
<Icon name="trash" size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="comment-text">{c.text}</div>
|
|
{c.resolved && <div className="comment-resolved">✓ Resolved</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
|
|
const me = window.ZAMPP_DATA?.ME;
|
|
const myInitials = me?.initials || 'ZG';
|
|
return (
|
|
<div className="comment-composer">
|
|
<div className="composer-head">
|
|
<div className="avatar" style={{ background: avatarColor(myInitials), width: 22, height: 22, fontSize: 10 }}>{myInitials}</div>
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)', fontWeight: 500 }}>{me?.name || 'You'}</span>
|
|
</div>
|
|
<div className="composer-tag">
|
|
<span className="badge accent">@ {msToTimecode(currentMs)}</span>
|
|
</div>
|
|
<textarea
|
|
className="composer-input"
|
|
placeholder="Leave a comment at this timecode…"
|
|
value={value}
|
|
onChange={function(e) { onChange(e.target.value); }}
|
|
onKeyDown={function(e) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSubmit(); }}
|
|
/>
|
|
<div className="composer-bar">
|
|
<span style={{ flex: 1 }} />
|
|
<span style={{ fontSize: 10.5, color: "var(--text-3)" }}>⌘↵ to send</span>
|
|
<button className="btn primary sm" onClick={onSubmit} disabled={!value.trim()}>Comment</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: '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>
|
|
);
|
|
}
|
|
|
|
function MetadataTab({ asset }) {
|
|
const rows = [
|
|
{ k: "Filename", v: asset.name },
|
|
{ k: "Duration", v: asset.duration || '—' },
|
|
{ k: "Resolution", v: asset.res || '—' },
|
|
{ k: "Codec", v: asset.codec || '—' },
|
|
{ k: "File size", v: asset.size || '—' },
|
|
{ k: "Status", v: asset.status || '—' },
|
|
{ k: "Updated", v: asset.updated || '—' },
|
|
{ k: "Project", v: asset.project || '—' },
|
|
];
|
|
return (
|
|
<div className="meta-table">
|
|
{rows.map(function(r) {
|
|
return (
|
|
<div key={r.k} className="meta-row">
|
|
<div className="meta-k">{r.k}</div>
|
|
<div className="meta-v">{r.v}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AudioTab({ asset }) {
|
|
return (
|
|
<div style={{ padding: "16px 0" }}>
|
|
<div style={{ background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: 8, padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
<Waveform seed={3} color="var(--accent)" />
|
|
<Waveform seed={7} color="var(--purple)" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function parseDuration(d) {
|
|
if (!d || d === '—' || typeof d !== 'string') return 0;
|
|
const parts = d.split(':');
|
|
if (parts.length < 2) return 0;
|
|
const nums = parts.map(Number);
|
|
if (nums.some(isNaN)) return 0;
|
|
if (nums.length === 3) return ((nums[0] * 60 + nums[1]) * 60 + nums[2]) * 1000;
|
|
return (nums[0] * 60 + nums[1]) * 1000;
|
|
}
|
|
|
|
function msToTimecode(ms) {
|
|
const total = Math.floor((ms || 0) / 1000);
|
|
const h = Math.floor(total / 3600);
|
|
const m = Math.floor((total % 3600) / 60);
|
|
const s = total % 60;
|
|
return String(h).padStart(2, "0") + ':' + String(m).padStart(2, "0") + ':' + String(s).padStart(2, "0");
|
|
}
|
|
|
|
function avatarColor(initials) {
|
|
let h = 0;
|
|
for (const c of (initials || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
|
|
return 'linear-gradient(135deg, hsl(' + (h % 360) + ' 60% 50%), hsl(' + ((h + 60) % 360) + ' 60% 45%))';
|
|
}
|
|
|
|
Object.assign(window, { AssetDetail, msToTimecode, parseDuration, avatarColor });
|