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>
115 lines
3.9 KiB
JavaScript
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 };
|
|
}
|