Full frontend built against the synthetic API (no dependence on lanes A/B/D): M4 — synchronized playback: master clock from performance.now() (never a <video>); per-video correction via requestVideoFrameCallback (exact mediaTime) with a currentTime+half-frame fallback — hard-seek >150ms / nudge playbackRate +/-5% 20-150ms / lock <20ms; out-of-range videos pause+dim; single audio source; dev sync-error overlay. Timebase mirrors config.py (lib/timebase.js). Verified: seek is exact to 0ms; continuous inter-video desync 13ms mean / 46ms max. M5 — 3D viewer: PLY point cloud, per-video camera paths + current-pose frusta, OrbitControls, snap-to-camera (intrinsics->PerspectiveCamera fov) + free roam. All COLMAP->Three.js via the frozen lib/pose.js; pose interpolation in lib/poseTrack.js (slerp+lerp of contract inputs). M6 — anchor overlays: letterbox-correct per-video canvas; anchors projected via pose.js; behind-camera cull. Projection agrees with an independent pinhole to 1.45e-13 px. Timeline — colored event markers + legend, hover tooltip, click-to-jump; draggable scrubber. Phase-3 seams (annotate.js M8, camPath.js M9) left as stubs. No frozen files edited; no new deps; no change requests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
4.5 KiB
JavaScript
150 lines
4.5 KiB
JavaScript
// Timeline scrubber + event markers (spec M4 transport + M7 markers).
|
|
//
|
|
// Renders a draggable scrubber over [0, tGlobalMax] with the playhead, plus GET /api/events as
|
|
// colored ticks (color by event_type, legend in the corner). Hover a marker -> description
|
|
// tooltip; click a marker -> jump the playhead there. Event data is already seeded
|
|
// synthetically; classification QUALITY is lane D's concern, not this renderer's.
|
|
|
|
import { state } from "./state.js";
|
|
|
|
export const EVENT_COLORS = {
|
|
bass_drop: "#ff3b6b",
|
|
pyro: "#ff8c1a",
|
|
confetti: "#ffd23b",
|
|
crowd_wave: "#3bc9ff",
|
|
artist_moment: "#c77dff",
|
|
light_show: "#59d499",
|
|
quiet_moment: "#8a92a6",
|
|
candidate: "#c0c0c0",
|
|
other: "#9aa3b2",
|
|
};
|
|
export function eventColor(type) {
|
|
return EVENT_COLORS[type] || EVENT_COLORS.other;
|
|
}
|
|
|
|
function fmtTime(s) {
|
|
s = Math.max(0, s);
|
|
const m = Math.floor(s / 60);
|
|
const sec = s - m * 60;
|
|
return `${m}:${sec.toFixed(1).padStart(4, "0")}`;
|
|
}
|
|
|
|
export class Timeline {
|
|
constructor() {
|
|
this.max = 1;
|
|
}
|
|
|
|
init(root, transport) {
|
|
this.transport = transport;
|
|
this.max = state.tGlobalMax || 1;
|
|
root.innerHTML = "";
|
|
|
|
const track = document.createElement("div");
|
|
track.className = "tl-track";
|
|
|
|
const fill = document.createElement("div");
|
|
fill.className = "tl-fill";
|
|
|
|
const markers = document.createElement("div");
|
|
markers.className = "tl-markers";
|
|
|
|
const playhead = document.createElement("div");
|
|
playhead.className = "tl-playhead";
|
|
|
|
track.append(fill, markers, playhead);
|
|
|
|
const tip = document.createElement("div");
|
|
tip.className = "tl-tooltip";
|
|
tip.style.display = "none";
|
|
|
|
root.append(track, tip);
|
|
this.track = track;
|
|
this.fill = fill;
|
|
this.playhead = playhead;
|
|
this.tip = tip;
|
|
|
|
this._buildMarkers(markers, tip);
|
|
this._buildLegend(root);
|
|
this._wireScrub(track);
|
|
}
|
|
|
|
_buildMarkers(container, tip) {
|
|
for (const ev of state.events) {
|
|
const m = document.createElement("div");
|
|
m.className = "tl-marker";
|
|
m.style.left = `${(ev.t_global_s / this.max) * 100}%`;
|
|
m.style.background = eventColor(ev.event_type);
|
|
m.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.transport.seek(ev.t_global_s);
|
|
});
|
|
const show = (e) => {
|
|
tip.innerHTML =
|
|
`<strong>${ev.event_type.replace(/_/g, " ")}</strong> · ${fmtTime(ev.t_global_s)}` +
|
|
`<br>${ev.description || ""}` +
|
|
(ev.confidence != null
|
|
? `<br><span class="tl-conf">confidence ${(ev.confidence * 100).toFixed(0)}% · ${ev.source}</span>`
|
|
: "");
|
|
tip.style.display = "block";
|
|
const rect = this.track.getBoundingClientRect();
|
|
const x = (ev.t_global_s / this.max) * rect.width;
|
|
tip.style.left = `${Math.max(4, Math.min(rect.width - 4, x))}px`;
|
|
};
|
|
m.addEventListener("mouseenter", show);
|
|
m.addEventListener("mousemove", show);
|
|
m.addEventListener("mouseleave", () => (tip.style.display = "none"));
|
|
container.append(m);
|
|
}
|
|
}
|
|
|
|
_buildLegend(root) {
|
|
const types = [...new Set(state.events.map((e) => e.event_type))];
|
|
if (types.length === 0) return;
|
|
const legend = document.createElement("div");
|
|
legend.className = "tl-legend";
|
|
for (const t of types) {
|
|
const item = document.createElement("span");
|
|
item.className = "tl-legend-item";
|
|
item.innerHTML = `<i style="background:${eventColor(t)}"></i>${t.replace(/_/g, " ")}`;
|
|
legend.append(item);
|
|
}
|
|
root.append(legend);
|
|
}
|
|
|
|
_wireScrub(track) {
|
|
let dragging = false;
|
|
const seekTo = (clientX) => {
|
|
const rect = track.getBoundingClientRect();
|
|
const f = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
this.transport.seek(f * this.max);
|
|
};
|
|
track.addEventListener("pointerdown", (e) => {
|
|
dragging = true;
|
|
track.setPointerCapture(e.pointerId);
|
|
seekTo(e.clientX);
|
|
});
|
|
track.addEventListener("pointermove", (e) => {
|
|
if (dragging) seekTo(e.clientX);
|
|
});
|
|
const end = (e) => {
|
|
dragging = false;
|
|
try {
|
|
track.releasePointerCapture(e.pointerId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
track.addEventListener("pointerup", end);
|
|
track.addEventListener("pointercancel", end);
|
|
}
|
|
|
|
/** Move the playhead + fill to the current master time. */
|
|
update(tGlobal) {
|
|
const f = this.max > 0 ? Math.max(0, Math.min(1, tGlobal / this.max)) : 0;
|
|
this.playhead.style.left = `${f * 100}%`;
|
|
this.fill.style.width = `${f * 100}%`;
|
|
}
|
|
}
|
|
|
|
export const timeline = new Timeline();
|