festifun/frontend/src/timeline.js
m3ultra ec22cbdf1f Lane C (M4+M5+M6 + timeline): synchronized viewer frontend
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>
2026-07-16 08:45:51 +10:00

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();