diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx
new file mode 100644
index 0000000..b66e1cc
--- /dev/null
+++ b/services/web-ui/public/screens-asset.jsx
@@ -0,0 +1,400 @@
+// screens-asset.jsx — asset detail (Frame.io-style player, filmstrip, comments)
+
+const { COMMENTS: SEED_COMMENTS } = window.ZAMPP_DATA;
+
+function AssetDetail({ asset, onClose }) {
+ const [playing, setPlaying] = React.useState(false);
+ const [currentMs, setCurrentMs] = React.useState(720000);
+ const [tab, setTab] = React.useState("comments");
+ const [showResolved, setShowResolved] = React.useState(false);
+ const [comments, setComments] = React.useState(SEED_COMMENTS);
+ const [newComment, setNewComment] = React.useState("");
+
+ const totalMs = parseDuration(asset.duration);
+
+ React.useEffect(() => {
+ if (!playing) return;
+ const i = setInterval(() => {
+ setCurrentMs(t => {
+ const next = t + 100;
+ if (next >= totalMs) { setPlaying(false); return totalMs; }
+ return next;
+ });
+ }, 100);
+ return () => clearInterval(i);
+ }, [playing, totalMs]);
+
+ const seek = (ms) => setCurrentMs(Math.max(0, Math.min(totalMs, ms)));
+ const addComment = () => {
+ if (!newComment.trim()) return;
+ const t = msToTimecode(currentMs);
+ setComments(c => [...c, {
+ id: `n${Date.now()}`,
+ who: "Zach Gaetano",
+ avatar: "ZG",
+ time: t,
+ real: "just now",
+ text: newComment,
+ resolved: false,
+ frame: Math.floor(currentMs / 1000 * 30),
+ }]);
+ setNewComment("");
+ };
+
+ const visibleComments = comments.filter(c => showResolved || !c.resolved);
+
+ return (
+
+
+
+
+
+ {asset.name}
+
+ v3
+
+
+ {asset.project}·{asset.bin}·updated {asset.updated}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!playing && (
+
+ )}
+
+ {visibleComments
+ .filter(c => Math.abs(parseDuration(c.time) - currentMs) < 200)
+ .map(c => (
+
+
{c.avatar}
+
{c.text}
+
+ ))}
+
+ {asset.status === "live" && (
+
+ LIVE · REC
+
+ )}
+
+ {msToTimecode(currentMs)}
+ / {asset.duration}
+
+
+
+
+
+
{msToTimecode(currentMs)}
+
+
{asset.duration}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {tab === "comments" && (
+
+ )}
+
+
+
+ {tab === "comments" && (
+
seek(parseDuration(c.time))} onResolve={(id) => {
+ setComments(cs => cs.map(c => c.id === id ? { ...c, resolved: !c.resolved } : c));
+ }} />
+ )}
+ {tab === "versions" && }
+ {tab === "metadata" && }
+ {tab === "audio" && }
+ {tab === "activity" && }
+
+
+
+
+
+
+
+
+ );
+}
+
+function PlaybackBar({ current, total, onSeek, comments }) {
+ const ref = React.useRef(null);
+ const handle = (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 = (current / total) * 100;
+ return (
+
+
+
+ {comments.map(c => {
+ const x = (parseDuration(c.time) / total) * 100;
+ return (
+
{ e.stopPropagation(); onSeek(parseDuration(c.time)); }}
+ >
+ {c.avatar}
+
+ );
+ })}
+
+ );
+}
+
+function FilmStrip({ seed, current, total, onSeek, comments }) {
+ const ref = React.useRef(null);
+ const frames = 28;
+ const handle = (e) => {
+ const r = ref.current.getBoundingClientRect();
+ onSeek(((e.clientX - r.left) / r.width) * total);
+ };
+ return (
+
+
+ {Array.from({ length: frames }).map((_, i) => (
+
+
+
+ ))}
+
+ {comments.map(c => {
+ const x = (parseDuration(c.time) / total) * 100;
+ return (
+
+ {c.avatar}
+
+ );
+ })}
+
+
+ {[0, 0.25, 0.5, 0.75, 1].map(p => (
+ {msToTimecode(p * total)}
+ ))}
+
+
+ );
+}
+
+function CommentsList({ comments, onSeek, onResolve }) {
+ if (comments.length === 0) {
+ return No comments yet.
;
+ }
+ return (
+
+ {comments.map(c => (
+
+
{c.avatar}
+
+
+
{c.who}
+
+
{c.real}
+
+
+
+
{c.text}
+ {c.resolved &&
✓ Resolved
}
+
+
+ ))}
+
+ );
+}
+
+function CommentComposer({ asset, currentMs, value, onChange, onSubmit }) {
+ return (
+
+
+
Reviewers
+
+ {["KM", "ZG", "MO", "JT"].map((a, i) => (
+
{a}
+ ))}
+
+
+
+
+ @ {msToTimecode(currentMs)}
+ Drawing tools
+
+
+ );
+}
+
+function VersionsTab() {
+ const versions = [
+ { v: "v3", current: true, by: "Kira Morales", when: "1h ago", note: "Color graded for sponsor compliance" },
+ { v: "v2", by: "Zach Gaetano", when: "yesterday", note: "Updated lower thirds, fixed audio dip" },
+ { v: "v1", by: "Jules Tran", when: "2d ago", note: "Initial edit from Stage_Cam_A" },
+ ];
+ return (
+
+ {versions.map(v => (
+
+
+
+
+ {v.v}
+ {v.current && Current}
+
+
{v.by} · {v.when}
+
{v.note}
+
+
+
+ ))}
+
+ );
+}
+
+function MetadataTab({ asset }) {
+ const rows = [
+ { k: "Filename", v: asset.name },
+ { k: "Duration", v: asset.duration },
+ { k: "Resolution", v: asset.res },
+ { k: "Frame rate", v: `${asset.fps} fps` },
+ { k: "Codec", v: asset.codec },
+ { k: "File size", v: asset.size },
+ { k: "Color space", v: "Rec. 709" },
+ { k: "Bit depth", v: "10-bit" },
+ { k: "Audio channels", v: "2.0 stereo" },
+ { k: "Audio sample rate", v: "48 kHz" },
+ { k: "Storage path", v: "s3://dragonmam/projects/protour/stage_a/master_001.mov" },
+ { k: "Checksum (xxh3)", v: "f4a8c2e9b15d3a07" },
+ ];
+ return (
+
+ {rows.map(r => (
+
+ ))}
+
+ );
+}
+
+function AudioTab({ asset }) {
+ return (
+
+
+ Stereo Mix
+ L
+ R
+
+
+
+
+
+
+ Loudness -14.2 LUFS
+ True peak -1.4 dBTP
+
+
+ );
+}
+
+function AssetActivityTab() {
+ return (
+
+ {window.ZAMPP_DATA.ACTIVITY.slice(0, 6).map(a => (
+
+
+
{a.who} {a.what} {a.target}
+
{a.time}
+
+ ))}
+
+ );
+}
+
+function parseDuration(d) {
+ const [h, m, s] = d.split(":").map(Number);
+ return ((h * 60 + m) * 60 + s) * 1000;
+}
+function msToTimecode(ms) {
+ const total = Math.floor(ms / 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 });