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>
206 lines
6.7 KiB
JavaScript
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();
|