dragonflight/services/web-ui/public/screens-asset.jsx
zgaetano 9d6bbf8112 fix(mam-api): /stream returns MP4 url + separate hls_url (fixes Premiere import)
The HLS-VOD work made GET /assets/:id/stream return the HLS playlist URL as
`url` whenever hls_s3_key was set. The Premiere plugin's "Import Proxy"
downloads `url` to a file and imports it — so it was saving an .m3u8 playlist
as .mp4, and Premiere rejected it ("unsupported compression type"). This hit
every YouTube asset (all get HLS generated), regardless of codec.

/stream now returns the directly-downloadable MP4 proxy as `url` (type mp4)
and the HLS playlist as a separate `hls_url`. The web player prefers `hls_url`
(so in-browser HLS playback is unchanged), while the already-installed plugin
gets a real MP4 again — no plugin reinstall needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:44:52 -04:00

1218 lines
51 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);
// Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error'
const [playerState, setPlayerState] = React.useState('idle');
const [playerError, setPlayerError] = React.useState(null);
// Array of {start, end} in milliseconds - populated from HTMLMediaElement.buffered
const [buffered, setBuffered] = React.useState([]);
// Wall-clock when waiting/stalled began (so we can show how long it's been hung)
const [stallStart, setStallStart] = React.useState(null);
const [stallElapsedMs, setStallElapsedMs] = 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.hls_url) {
// Prefer HLS for in-browser playback; `url` stays the MP4 proxy
// (used by the Premiere plugin importer + as a fallback).
setStreamUrl(r.hls_url);
setStreamType('hls');
} else 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]);
// Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg).
// Falls back to nothing if not ready yet - user can right-click → Re-generate.
React.useEffect(() => {
if (!assetId) return;
let cancelled = false;
setFilmFrames([]);
setFilmstripLoading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/filmstrip')
.then(function(r) {
if (cancelled) return;
if (!r || !r.url) { setFilmstripLoading(false); return; }
// Fetch the JSON array of base64 frames from the signed S3 URL
return fetch(r.url)
.then(function(res) { return res.json(); })
.then(function(frames) {
if (!cancelled && Array.isArray(frames) && frames.length) {
setFilmFrames(frames);
}
});
})
.catch(function() {})
.finally(function() { if (!cancelled) setFilmstripLoading(false); });
return function() { cancelled = true; };
}, [assetId, 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; });
}
};
// Read HTMLMediaElement.buffered into a JSON-friendly array of {start, end} ms.
// Called from onProgress and onTimeUpdate so the UI tracks buffer growth in real time.
const updateBuffered = function() {
const v = videoRef.current;
if (!v || !v.buffered || v.buffered.length === 0) { setBuffered([]); return; }
const ranges = [];
for (let i = 0; i < v.buffered.length; i++) {
ranges.push({
start: v.buffered.start(i) * 1000,
end: v.buffered.end(i) * 1000,
});
}
setBuffered(ranges);
};
// Tick the stall elapsed counter once per second when we're hung
React.useEffect(() => {
if (!stallStart) { setStallElapsedMs(0); return; }
const i = setInterval(() => setStallElapsedMs(Date.now() - stallStart), 250);
return () => clearInterval(i);
}, [stallStart]);
// #143 - if the player is stalled within 250 ms of EOF for more than 1.2 s,
// treat it as a clean end. Avoids the silent-freeze users hit when seeking
// to the last instant of a clip.
React.useEffect(() => {
if (!stallStart) return;
if (!videoRef.current || !totalMs) return;
const id = setTimeout(() => {
const v = videoRef.current;
if (!v) return;
const posMs = (v.currentTime || 0) * 1000;
if (totalMs - posMs <= 250 && (playerState === 'waiting' || playerState === 'stalled')) {
try { v.pause(); } catch (_) {}
setPlaying(false);
setPlayerState('paused');
setStallStart(null);
}
}, 1200);
return () => clearTimeout(id);
}, [stallStart, totalMs, playerState]);
const seek = function(ms) {
// #143 - seeking exactly to `totalMs` parked the playhead one micro-sample
// past the last decoded frame; the player then asked S3 for a range past
// EOF and stalled silently. Pull the clamp back 50 ms so the final frames
// are reachable but the player never asks for bytes past the file size.
const upperBoundMs = Math.max(0, (totalMs || 0) - 50);
const clamped = Math.max(0, Math.min(upperBoundMs, 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); });
};
const regenFilmstrip = function() {
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
.then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
};
// 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" aria-label="Back" 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" aria-label="More actions" 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);
updateBuffered();
}}
onLoadedMetadata={function() {
if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000);
setPlayerState('paused');
}}
onProgress={updateBuffered}
onPlay={function() { setPlaying(true); setPlayerState('playing'); setStallStart(null); }}
onPlaying={function() { setPlayerState('playing'); setStallStart(null); setPlayerError(null); }}
onPause={function() { setPlaying(false); setPlayerState('paused'); setStallStart(null); }}
onSeeking={function() { setPlayerState('seeking'); setStallStart(Date.now()); }}
onSeeked={function() { setStallStart(null); if (videoRef.current?.paused) setPlayerState('paused'); }}
onWaiting={function() { setPlayerState('waiting'); if (!stallStart) setStallStart(Date.now()); }}
onStalled={function() { setPlayerState('stalled'); if (!stallStart) setStallStart(Date.now()); }}
onCanPlay={function() { setStallStart(null); }}
onCanPlayThrough={function() { setStallStart(null); }}
onError={function(e) {
const err = videoRef.current?.error;
const msg = err ? `MediaError code=${err.code} message=${err.message || '(none)'}` : 'unknown error';
setPlayerState('error');
setPlayerError(msg);
console.error('[player]', msg, e);
}}
onEnded={function() { setPlaying(false); setPlayerState('paused'); }}
/>
) : (
<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>
)}
{/* Player health badge: shows when waiting/stalled so the freeze is visible */}
{streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
{playerState === 'seeking' && 'SEEKING'}
{playerState === 'waiting' && 'BUFFERING'}
{playerState === 'stalled' && 'STALLED'}
{playerState === 'error' && 'ERROR'}
{stallElapsedMs > 500 && playerState !== 'error' && ` · ${(stallElapsedMs/1000).toFixed(1)}s`}
</span>
{playerError && (
<span style={{ fontSize: 10.5, color: 'var(--text-3)', background: 'rgba(0,0,0,0.6)', padding: '2px 6px', borderRadius: 3, fontFamily: 'var(--font-mono)' }}>
{playerError}
</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" aria-label={playing ? 'Pause' : 'Play'} 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} buffered={buffered} />
<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"
aria-label="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"
aria-label="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={regenFilmstrip}
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={regenFilmstrip}
/>
)}
{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, buffered }) {
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;
const bufferedRanges = Array.isArray(buffered) ? buffered : [];
return (
<div className="playback-bar" ref={ref} onClick={handle}>
{/* Buffered byte ranges: translucent grey segments showing what the browser has loaded */}
{total > 0 && bufferedRanges.map((br, i) => {
const left = Math.max(0, (br.start / total) * 100);
const right = Math.min(100, (br.end / total) * 100);
if (right <= left) return null;
return (
<div key={'buf-' + i} style={{
position: 'absolute', top: 0, bottom: 0,
left: left + '%', width: (right - left) + '%',
background: 'rgba(255,255,255,0.18)',
borderRadius: 'inherit',
pointerEvents: 'none',
}} />
);
})}
<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 = !!asset.filmstrip_s3_key;
const filmstripReady = Array.isArray(filmFrames) && filmFrames.length > 0;
// Thumbnail endpoint returns a signed URL, not raw image bytes
const [thumbUrl, setThumbUrl] = React.useState(null);
React.useEffect(function() {
if (!hasThumb) return;
window.ZAMPP_API.fetch('/assets/' + asset.id + '/thumbnail')
.then(function(r) { if (r && r.url) setThumbUrl(r.url); })
.catch(function() {});
}, [asset.id, hasThumb]);
// 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'}
/>
{/* 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'}
>
{thumbUrl && (
<img
src={thumbUrl}
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 ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet: right-click filmstrip or click Re-generate'}
icon="editor"
actionLabel="Re-generate"
onAction={onRegenFilmstrip}
disabled={filmstripLoading}
>
{filmstripReady && (
<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>
)}
{!filmstripReady && filmstripLoading && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Fetching filmstrip from server</div>
)}
</FileRow>
</div>
);
}
function MetadataTab({ asset }) {
var 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 || '·' },
];
var audioMeta = asset.audio_metadata;
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) {
rows.push({ k: "Audio tracks", v: audioMeta.length });
audioMeta.forEach(function(tr, i) {
var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
var parts = [tr.codec || '·', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·', tr.bit_depth ? tr.bit_depth + '-bit' : '·', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·'];
if (tr.language) parts.push(tr.language);
rows.push({ k: " " + label, v: parts.join(' · ') });
});
}
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>
);
}
var _AUDIO_TRACK_COLORS = [
'var(--accent)',
'var(--purple)',
'var(--success)',
'var(--warning)',
'var(--danger)',
'#6B7280',
'#F472B6',
'#34D399',
];
function AudioTab({ asset }) {
var assetId = asset && asset.id;
var [tracks, setTracks] = React.useState([]);
var [loading, setLoading] = React.useState(true);
var [masterVol, setMasterVol] = React.useState(100);
var [trackState, setTrackState] = React.useState({});
var [levels, setLevels] = React.useState({});
var analyserRef = React.useRef(null);
var rafRef = React.useRef(null);
var audioCtxRef = React.useRef(null);
var sourceRef = React.useRef(null);
React.useEffect(function() {
if (!assetId) return;
var cancelled = false;
setLoading(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/audio')
.then(function(r) {
if (cancelled) return;
var t = (r && r.tracks) || [];
setTracks(t);
var initial = {};
t.forEach(function(tr, i) {
initial[i] = { muted: false, solo: false, volume: 100 };
});
setTrackState(initial);
})
.catch(function() { setTracks([]); })
.finally(function() { if (!cancelled) setLoading(false); });
return function() { cancelled = true; };
}, [assetId]);
var toggleMute = function(i) {
setTrackState(function(prev) {
var s = Object.assign({}, prev);
s[i] = Object.assign({}, s[i] || { muted: false, solo: false, volume: 100 });
s[i].muted = !s[i].muted;
return s;
});
};
var toggleSolo = function(i) {
setTrackState(function(prev) {
var s = Object.assign({}, prev);
s[i] = Object.assign({}, s[i] || { muted: false, solo: false, volume: 100 });
s[i].solo = !s[i].solo;
return s;
});
};
var setTrackVol = function(i, v) {
setTrackState(function(prev) {
var s = Object.assign({}, prev);
s[i] = Object.assign({}, s[i] || { muted: false, solo: false, volume: 100 });
s[i].volume = v;
return s;
});
};
var anySolo = tracks.some(function(_, i) { return trackState[i] && trackState[i].solo; });
React.useEffect(function() {
var video = document.querySelector('.player-canvas video');
if (!video || !window.AudioContext) return;
var ctx, source, analyser, leftData, rightData;
try {
ctx = new (window.AudioContext || window.webkitAudioContext)();
source = ctx.createMediaElementSource(video);
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.8;
var splitter = ctx.createChannelSplitter(2);
source.connect(analyser);
source.connect(ctx.destination);
analyser.connect(splitter);
var leftAnalyser = ctx.createAnalyser();
leftAnalyser.fftSize = 256;
leftAnalyser.smoothingTimeConstant = 0.8;
var rightAnalyser = ctx.createAnalyser();
rightAnalyser.fftSize = 256;
rightAnalyser.smoothingTimeConstant = 0.8;
splitter.connect(leftAnalyser, 0);
splitter.connect(rightAnalyser, 1);
audioCtxRef.current = ctx;
sourceRef.current = source;
analyserRef.current = { left: leftAnalyser, right: rightAnalyser };
leftData = new Uint8Array(leftAnalyser.frequencyBinCount);
rightData = new Uint8Array(rightAnalyser.frequencyBinCount);
} catch (e) {
return;
}
var running = true;
var tick = function() {
if (!running) return;
if (analyserRef.current) {
analyserRef.current.left.getByteFrequencyData(leftData);
analyserRef.current.right.getByteFrequencyData(rightData);
var lAvg = 0, rAvg = 0;
for (var j = 0; j < leftData.length; j++) { lAvg += leftData[j]; rAvg += rightData[j]; }
lAvg = lAvg / leftData.length / 255;
rAvg = rAvg / rightData.length / 255;
setLevels({ L: lAvg, R: rAvg });
}
rafRef.current = requestAnimationFrame(tick);
};
tick();
return function() {
running = false;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
try { ctx.close(); } catch (_) {}
};
}, [assetId, tracks.length]);
if (loading) {
return <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>Loading audio</div>;
}
if (!tracks.length) {
return (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
<Icon name="audio" size={20} style={{ opacity: 0.4, display: 'block', margin: '0 auto 8px' }} />
No audio tracks found. Either this asset has no audio, or it has not been probed yet.
</div>
);
}
return (
<div className="audio-tab">
<div className="audio-tracks">
{tracks.map(function(tr, i) {
var st = trackState[i] || { muted: false, solo: false, volume: 100 };
var isAudible = st.muted ? false : (anySolo ? st.solo : true);
var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length];
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null;
var codecLabel = tr.codec || '·';
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·';
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '·';
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·';
return (
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
<div className="audio-track-label" style={{ color: color }}>
<Icon name="audio" size={13} />
<span style={{ fontWeight: 600, fontSize: 12.5 }}>{trackName}</span>
{langTag}
</div>
<div className="audio-track-waveform">
<Waveform seed={tr.index || i} color={color} />
</div>
<div className="audio-track-meters">
<AudioLevelMeter level={isAudible ? (levels.L || 0) : 0} label="L" />
<AudioLevelMeter level={isAudible ? (levels.R || 0) : 0} label="R" />
</div>
<div className="audio-track-meta mono">
<span>{codecLabel}</span>
<span>{chLabel}</span>
<span>{srLabel}</span>
<span>{bdLabel}</span>
<span>{brLabel}</span>
</div>
<div className="audio-track-controls">
<button className={'audio-btn mute' + (st.muted ? ' active' : '')} onClick={function() { toggleMute(i); }} title="Mute">M</button>
<button className={'audio-btn solo' + (st.solo ? ' active' : '')} onClick={function() { toggleSolo(i); }} title="Solo">S</button>
<div className="audio-fader">
<input type="range" min="0" max="100" value={st.volume}
onChange={function(e) { setTrackVol(i, parseInt(e.target.value, 10)); }}
title={st.volume + '%'}
/>
<span className="mono">{st.volume}</span>
</div>
</div>
</div>
);
})}
</div>
<div className="audio-master">
<div className="audio-master-label">Master</div>
<div className="audio-master-meters">
<AudioLevelMeter level={levels.L || 0} label="L" tall />
<AudioLevelMeter level={levels.R || 0} label="R" tall />
</div>
<div className="audio-fader master-fader">
<input type="range" min="0" max="100" value={masterVol}
onChange={function(e) { setMasterVol(parseInt(e.target.value, 10)); }}
title={masterVol + '%'}
/>
<span className="mono">{masterVol}</span>
</div>
</div>
</div>
);
}
function AudioLevelMeter({ level, label, tall }) {
var segs = tall ? 28 : 16;
var pct = Math.max(0, Math.min(1, level));
return (
<div className="audio-level-meter">
<div className="audio-level-bar">
{Array.from({ length: segs }).map(function(_, i) {
var v = i / segs;
var on = v < pct;
var color = v < 0.6 ? 'var(--success)' : v < 0.85 ? 'var(--warning)' : 'var(--danger)';
return <div key={i} className="audio-level-seg" style={{ background: on ? color : 'var(--bg-3)', opacity: on ? 1 : 0.3 }} />;
})}
</div>
<span className="audio-level-label mono">{label}</span>
</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 });