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

115 lines
3.9 KiB
JavaScript

// <video> grid + per-video transparent overlay canvases (spec M4 / M6).
//
// Each cell stacks a <video> (object-fit: contain) under a transparent <canvas> that exactly
// covers the element box. Because object-fit letterboxes, the video's CONTENT rect is smaller
// than the element (pitfall #4) — contentRect() computes it so overlays.js can map projected
// video-pixel coordinates to the right place on the canvas.
import { state, emit } from "./state.js";
/**
* Build the grid inside `container`. Returns cell descriptors:
* { id, meta, el, video, canvas, statusEl, audioBtn, snapBtn }
*/
export function createVideoGrid(container, videos, apiBase, transport) {
container.innerHTML = "";
const cells = [];
for (const meta of videos) {
const el = document.createElement("div");
el.className = "cell";
el.dataset.id = String(meta.id);
const video = document.createElement("video");
video.className = "cell-video";
video.src = apiBase + meta.url;
video.muted = true;
video.loop = false;
video.playsInline = true;
video.setAttribute("playsinline", "");
video.setAttribute("webkit-playsinline", "");
video.preload = "auto";
// No crossOrigin: we never read video pixels (overlays are a separate canvas), so we avoid
// making playback depend on CORS headers for the /media mount.
const canvas = document.createElement("canvas");
canvas.className = "cell-overlay";
const bar = document.createElement("div");
bar.className = "cell-bar";
const label = document.createElement("span");
label.className = "cell-label";
label.textContent = meta.filename;
const audioBtn = document.createElement("button");
audioBtn.className = "cell-btn audio-btn";
audioBtn.title = "Use this video's audio";
audioBtn.textContent = "🔈";
audioBtn.addEventListener("click", (e) => {
e.stopPropagation();
transport.setAudioSource(meta.id);
});
const snapBtn = document.createElement("button");
snapBtn.className = "cell-btn snap-btn";
snapBtn.title = "Snap 3D view to this camera";
snapBtn.textContent = "⛶";
// wired in main.js (needs scene3d)
const enableBtn = document.createElement("button");
enableBtn.className = "cell-btn enable-btn";
enableBtn.title = "Enable / disable this video";
enableBtn.textContent = "◉";
enableBtn.addEventListener("click", (e) => {
e.stopPropagation();
const now = state.enabled[meta.id] !== false;
state.enabled[meta.id] = !now;
emit("enabled", { id: meta.id, enabled: !now });
});
const status = document.createElement("span");
status.className = "cell-status";
bar.append(label, status, enableBtn, snapBtn, audioBtn);
el.append(video, canvas, bar);
container.append(el);
// Clicking the video frame selects its audio (natural "listen to this one" gesture).
video.addEventListener("click", () => transport.setAudioSource(meta.id));
cells.push({ id: meta.id, meta, el, video, canvas, statusEl: status, audioBtn, snapBtn });
}
return cells;
}
/**
* The video's displayed content rectangle inside its element, in CSS pixels, accounting for
* object-fit: contain letterboxing. Maps native video pixels -> element pixels via:
* x = ox + (u / vw) * cw , y = oy + (v / vh) * ch
*/
export function contentRect(video) {
const elW = video.clientWidth;
const elH = video.clientHeight;
const vw = video.videoWidth || 1;
const vh = video.videoHeight || 1;
const elAspect = elW / elH;
const vidAspect = vw / vh;
let cw, ch, ox, oy;
if (vidAspect > elAspect) {
// wider than the box -> pillarbox: full width, bars top/bottom
cw = elW;
ch = elW / vidAspect;
ox = 0;
oy = (elH - ch) / 2;
} else {
// taller than the box -> letterbox: full height, bars left/right
ch = elH;
cw = elH * vidAspect;
oy = 0;
ox = (elW - cw) / 2;
}
return { ox, oy, cw, ch, elW, elH, vw, vh };
}