festifun/frontend/src/main.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

206 lines
6.7 KiB
JavaScript

// Festival 4D viewer — composition root (spec M4/M5/M6 + timeline markers).
//
// Boots by loading the frozen synthetic API (manifest, poses, anchors, events), builds the
// video grid + 3D scene + timeline, wires transport controls + keyboard shortcuts, and runs a
// single master animation loop that: advances the master clock & corrects every video
// (transport), redraws each overlay for its displayed frame, updates the 3D frusta / follow-cam,
// moves the timeline playhead, and refreshes the dev sync overlay. The master clock lives in
// transport.js and is derived from performance.now() — never from a <video> element.
import { state, on, API_BASE, referenceVideoId } from "./state.js";
import { transport } from "./transport.js";
import { createVideoGrid } from "./videoGrid.js";
import { drawOverlay, projectWorldToVideoPx } from "./overlays.js";
import { poseAt } from "./lib/poseTrack.js";
import { scene3d, videoColor } from "./scene3d.js";
import { timeline } from "./timeline.js";
async function getJSON(path) {
const resp = await fetch(API_BASE + path);
if (!resp.ok) throw new Error(`${path} -> HTTP ${resp.status}`);
return resp.json();
}
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")}`;
}
function el(id) {
return document.getElementById(id);
}
async function boot() {
const loading = el("loading");
try {
const manifest = await getJSON("/api/manifest");
state.manifest = manifest;
state.videos = manifest.videos;
state.tGlobalMax = manifest.t_global_max;
state.hasPoses = manifest.has_poses;
for (const v of state.videos) state.enabled[v.id] = true;
state.audioSourceId = referenceVideoId();
const [anchors, events] = await Promise.all([
getJSON("/api/anchors"),
getJSON("/api/events"),
]);
state.anchors = anchors;
state.events = events;
if (state.hasPoses) {
const lists = await Promise.all(
state.videos.map((v) => getJSON(`/api/videos/${v.id}/poses`))
);
state.videos.forEach((v, i) => (state.poses[v.id] = lists[i]));
}
buildUI();
startLoop();
loading.style.display = "none";
// Dev-only hook so the transport/scene can be driven in tests (e.g. a setTimeout pump when
// a headless pane throttles rAF). Never present in a production build.
if (import.meta.env?.DEV) {
window.__f4d = {
state, transport, scene3d, timeline, cells: () => cells,
poseAt, projectWorldToVideoPx,
};
}
} catch (err) {
console.error("[festival4d] boot failed", err);
loading.innerHTML = `<div class="err">Failed to load the project.<br><code>${err.message}</code>` +
`<br><span class="dim">Is the backend running? <code>python -m festival4d serve</code></span></div>`;
}
}
let cells = [];
function buildUI() {
// Video grid
const grid = el("video-grid");
cells = createVideoGrid(grid, state.videos, API_BASE, transport);
transport.register(cells);
// Per-video snap buttons -> scene3d (grid module leaves them unwired).
cells.forEach((cell, i) => {
cell.el.style.setProperty("--accent", videoColor(i));
cell.snapBtn.addEventListener("click", (e) => {
e.stopPropagation();
scene3d.snapTo(cell.id);
});
});
// 3D scene
scene3d.init(el("scene3d"));
// Timeline
timeline.init(el("timeline"), transport);
wireTransportControls();
wireKeyboard();
positionVideosWhenReady();
// Reflect follow/roam state on the free-roam button.
on("follow", (id) => {
el("btn-roam").classList.toggle("active", id == null);
});
}
function wireTransportControls() {
el("btn-play").addEventListener("click", () => transport.toggle());
el("btn-back").addEventListener("click", () => transport.seekBy(-1));
el("btn-fwd").addEventListener("click", () => transport.seekBy(1));
el("rate-select").addEventListener("change", (e) =>
transport.setRate(parseFloat(e.target.value))
);
el("btn-roam").addEventListener("click", () => scene3d.freeRoam());
}
function wireKeyboard() {
window.addEventListener("keydown", (e) => {
if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
if (e.code === "Space") {
e.preventDefault();
transport.toggle();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
transport.seekBy(e.shiftKey ? -5 : -1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
transport.seekBy(e.shiftKey ? 5 : 1);
} else if (e.code === "Escape" || e.code === "Digit0") {
scene3d.freeRoam();
} else if (/^Digit[1-9]$/.test(e.code)) {
const n = parseInt(e.code.slice(5), 10) - 1;
const v = state.videos[n];
if (v) scene3d.snapTo(v.id);
}
});
}
function positionVideosWhenReady() {
let remaining = cells.length;
if (remaining === 0) return;
const done = () => {
if (--remaining === 0) transport.seek(0); // park every video on its t=0 frame
};
for (const { video } of cells) {
if (video.readyState >= 1) done();
else video.addEventListener("loadedmetadata", done, { once: true });
}
}
function startLoop() {
const frame = () => {
transport.tick();
for (const cell of cells) drawOverlay(cell);
scene3d.update();
timeline.update(state.tGlobal);
updateHud();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
function updateHud() {
el("btn-play").textContent = state.playing ? "⏸" : "▶";
el("time-display").textContent = `${fmtTime(state.tGlobal)} / ${fmtTime(state.tGlobalMax)}`;
// Dev sync overlay + per-cell status.
let devRows = "";
for (const cell of cells) {
const { id, meta } = cell;
const err = state.syncErrorMs[id];
const inRange = state.inRange[id];
const enabled = state.enabled[id] !== false;
let label, cls;
if (!enabled) {
label = "disabled";
cls = "dim";
} else if (!inRange) {
label = "out of range";
cls = "dim";
} else if (err == null) {
label = "—";
cls = "dim";
} else {
label = `${err >= 0 ? "+" : ""}${err.toFixed(0)} ms`;
cls = Math.abs(err) < 50 ? "ok" : "bad";
}
devRows += `<div class="dev-row"><span>${meta.filename}</span><span class="${cls}">${label}</span></div>`;
// Cell overlay state (dim out-of-range / disabled, mark audio + follow).
cell.el.classList.toggle("out", !inRange || !enabled);
cell.el.classList.toggle("audio-on", state.audioSourceId === id);
cell.el.classList.toggle("followed", state.followCameraId === id);
cell.statusEl.textContent = enabled ? (inRange ? label : "waiting…") : "off";
cell.statusEl.className = `cell-status ${cls}`;
}
el("dev-rows").innerHTML = devRows;
}
boot();