Merge branch 'lane/c-viewer'
# Conflicts: # plan/status/lane-C.md
This commit is contained in:
commit
9d17b474ea
@ -5,39 +5,206 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Festival 4D</title>
|
<title>Festival 4D</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0b0d12;
|
||||||
|
--panel: #141824;
|
||||||
|
--panel-2: #1c2233;
|
||||||
|
--border: #232838;
|
||||||
|
--text: #e6e8ee;
|
||||||
|
--dim: #8a92a6;
|
||||||
|
--ok: #59d499;
|
||||||
|
--bad: #ff6b6b;
|
||||||
|
--accent: #59d499;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
background: #0b0d12;
|
background: var(--bg);
|
||||||
color: #e6e8ee;
|
color: var(--text);
|
||||||
padding: 2rem;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
|
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||||
.sub { color: #8a92a6; margin-bottom: 1.5rem; }
|
|
||||||
.card {
|
/* Top bar */
|
||||||
background: #141824;
|
#topbar {
|
||||||
border: 1px solid #232838;
|
display: flex; align-items: baseline; gap: 0.6rem;
|
||||||
border-radius: 10px;
|
padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--border); flex: 0 0 auto;
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
}
|
||||||
.ok { color: #59d499; }
|
#topbar h1 { margin: 0; font-size: 1.05rem; font-weight: 700; letter-spacing: 0.2px; }
|
||||||
.bad { color: #ff6b6b; }
|
#topbar .sub { color: var(--dim); font-size: 0.8rem; }
|
||||||
table { border-collapse: collapse; width: 100%; }
|
|
||||||
th, td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid #232838; }
|
/* Stage: 3D on the left, video grid on the right */
|
||||||
th { color: #8a92a6; font-weight: 600; }
|
#stage { flex: 1 1 auto; display: flex; min-height: 0; }
|
||||||
code { background: #1c2233; padding: 0.1rem 0.35rem; border-radius: 4px; }
|
#scene-pane { flex: 1 1 55%; position: relative; min-width: 0; background: #0a0c11; }
|
||||||
.pill { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
|
#scene3d { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
.pill.ok { background: rgba(89,212,153,0.15); }
|
#scene-controls {
|
||||||
.pill.bad { background: rgba(255,107,107,0.15); }
|
position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; z-index: 5;
|
||||||
|
}
|
||||||
|
#scene-hint {
|
||||||
|
position: absolute; bottom: 0.5rem; left: 0.6rem; color: var(--dim);
|
||||||
|
font-size: 0.72rem; z-index: 5; pointer-events: none;
|
||||||
|
background: rgba(10,12,17,0.6); padding: 0.2rem 0.45rem; border-radius: 5px;
|
||||||
|
}
|
||||||
|
#grid-pane {
|
||||||
|
flex: 1 1 45%; min-width: 0; border-left: 1px solid var(--border);
|
||||||
|
overflow: auto; background: #0d1017;
|
||||||
|
}
|
||||||
|
#video-grid {
|
||||||
|
display: grid; gap: 6px; padding: 6px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
position: relative; aspect-ratio: 16 / 9; background: #000;
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
|
||||||
|
transition: opacity 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.cell.out { opacity: 0.32; }
|
||||||
|
.cell.audio-on { border-color: var(--accent); }
|
||||||
|
.cell.followed { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
|
.cell-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.cell-overlay { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.cell-bar {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center;
|
||||||
|
gap: 0.35rem; padding: 0.25rem 0.4rem; font-size: 0.72rem;
|
||||||
|
background: linear-gradient(transparent, rgba(6,8,14,0.85));
|
||||||
|
}
|
||||||
|
.cell-label { font-weight: 600; }
|
||||||
|
.cell-status { color: var(--dim); margin-left: 0.1rem; }
|
||||||
|
.cell-status.ok { color: var(--ok); }
|
||||||
|
.cell-status.bad { color: var(--bad); }
|
||||||
|
.cell-btn {
|
||||||
|
margin-left: auto; background: rgba(255,255,255,0.08); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 5px; cursor: pointer;
|
||||||
|
width: 22px; height: 20px; font-size: 0.72rem; line-height: 1; padding: 0;
|
||||||
|
}
|
||||||
|
.cell-btn + .cell-btn { margin-left: 0.25rem; }
|
||||||
|
.cell-btn:hover { background: rgba(255,255,255,0.16); }
|
||||||
|
.cell.audio-on .audio-btn { background: var(--accent); color: #071018; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Bottom bar: transport + timeline */
|
||||||
|
#bottombar {
|
||||||
|
flex: 0 0 auto; border-top: 1px solid var(--border); padding: 0.5rem 0.9rem 0.7rem;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
#controls { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.btn {
|
||||||
|
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; cursor: pointer; padding: 0.3rem 0.6rem; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #262d42; }
|
||||||
|
.btn.active { background: var(--accent); color: #071018; border-color: var(--accent); }
|
||||||
|
#btn-play { min-width: 2.4rem; font-size: 1rem; }
|
||||||
|
#time-display { font-variant-numeric: tabular-nums; color: var(--dim); margin-left: 0.2rem; }
|
||||||
|
select.btn { padding-right: 0.4rem; }
|
||||||
|
.spacer { flex: 1 1 auto; }
|
||||||
|
.ctl-label { color: var(--dim); font-size: 0.78rem; }
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
#timeline { position: relative; }
|
||||||
|
.tl-track {
|
||||||
|
position: relative; height: 26px; background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; cursor: pointer; overflow: visible;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.tl-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0;
|
||||||
|
background: rgba(89,212,153,0.14); border-radius: 6px 0 0 6px; }
|
||||||
|
.tl-markers { position: absolute; inset: 0; }
|
||||||
|
.tl-marker {
|
||||||
|
position: absolute; top: 0; bottom: 0; width: 3px; margin-left: -1.5px;
|
||||||
|
border-radius: 2px; cursor: pointer; box-shadow: 0 0 0 1px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
.tl-marker:hover { width: 5px; margin-left: -2.5px; }
|
||||||
|
.tl-playhead {
|
||||||
|
position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px;
|
||||||
|
background: #fff; box-shadow: 0 0 6px rgba(255,255,255,0.6); pointer-events: none;
|
||||||
|
}
|
||||||
|
.tl-tooltip {
|
||||||
|
position: absolute; bottom: 34px; transform: translateX(-50%);
|
||||||
|
background: #0f1420; border: 1px solid var(--border); border-radius: 7px;
|
||||||
|
padding: 0.4rem 0.55rem; font-size: 0.78rem; max-width: 260px; z-index: 20;
|
||||||
|
pointer-events: none; box-shadow: 0 6px 20px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.tl-tooltip strong { text-transform: capitalize; }
|
||||||
|
.tl-conf { color: var(--dim); font-size: 0.72rem; }
|
||||||
|
.tl-legend {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.45rem;
|
||||||
|
font-size: 0.72rem; color: var(--dim);
|
||||||
|
}
|
||||||
|
.tl-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; text-transform: capitalize; }
|
||||||
|
.tl-legend-item i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
||||||
|
|
||||||
|
/* Dev sync overlay */
|
||||||
|
#dev-overlay {
|
||||||
|
position: fixed; top: 0.6rem; right: 0.6rem; z-index: 30;
|
||||||
|
background: rgba(15,20,32,0.9); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0.45rem 0.6rem; font-size: 0.72rem; min-width: 132px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
#dev-overlay .hd { color: var(--dim); font-weight: 600; margin-bottom: 0.3rem; }
|
||||||
|
.dev-row { display: flex; justify-content: space-between; gap: 0.8rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.ok { color: var(--ok); }
|
||||||
|
.bad { color: var(--bad); }
|
||||||
|
.dim { color: var(--dim); }
|
||||||
|
|
||||||
|
/* Loading / error */
|
||||||
|
#loading {
|
||||||
|
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg); z-index: 100; color: var(--dim);
|
||||||
|
}
|
||||||
|
#loading .err { text-align: center; color: var(--text); }
|
||||||
|
code { background: var(--panel-2); padding: 0.1rem 0.35rem; border-radius: 4px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Festival 4D</h1>
|
<div id="app">
|
||||||
<div class="sub">Foundation hello page — proves the API contract, CORS, and the frozen pose helper.</div>
|
<div id="topbar">
|
||||||
<div id="app"><div class="card">Loading…</div></div>
|
<h1>Festival 4D</h1>
|
||||||
|
<span class="sub">synchronized multi-cam replay · 3D viewer · x-ray overlays</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stage">
|
||||||
|
<div id="scene-pane">
|
||||||
|
<canvas id="scene3d"></canvas>
|
||||||
|
<div id="scene-controls">
|
||||||
|
<button class="btn active" id="btn-roam" title="Free roam (Esc)">Free roam</button>
|
||||||
|
</div>
|
||||||
|
<div id="scene-hint">drag to orbit · click a camera or press 1–9 to snap · Esc to detach</div>
|
||||||
|
</div>
|
||||||
|
<div id="grid-pane">
|
||||||
|
<div id="video-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bottombar">
|
||||||
|
<div id="controls">
|
||||||
|
<button class="btn" id="btn-back" title="Back 1s (←)">⏪</button>
|
||||||
|
<button class="btn" id="btn-play" title="Play/Pause (Space)">▶</button>
|
||||||
|
<button class="btn" id="btn-fwd" title="Forward 1s (→)">⏩</button>
|
||||||
|
<span id="time-display">0:00.0 / 0:00.0</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="ctl-label">speed</span>
|
||||||
|
<select class="btn" id="rate-select">
|
||||||
|
<option value="0.25">0.25×</option>
|
||||||
|
<option value="0.5">0.5×</option>
|
||||||
|
<option value="1" selected>1×</option>
|
||||||
|
<option value="2">2×</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="timeline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="dev-overlay">
|
||||||
|
<div class="hd">sync error</div>
|
||||||
|
<div id="dev-rows"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading">Loading project…</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
57
frontend/src/lib/poseTrack.js
Normal file
57
frontend/src/lib/poseTrack.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Pose lookup + interpolation over a video's `camera_poses` track (from GET /api/videos/{id}/poses).
|
||||||
|
//
|
||||||
|
// Poses arrive keyed by frame_idx / t_video_s (every ~0.5 s on the synthetic fixture, all
|
||||||
|
// registered). To render a smooth frustum / follow-cam at an arbitrary t_video we interpolate
|
||||||
|
// BETWEEN stored poses in COLMAP space — slerp the world->camera quaternion, lerp the
|
||||||
|
// translation — then hand the interpolated (q, t) to the FROZEN colmapToThreejs() helper.
|
||||||
|
// We interpolate the CONTRACT INPUTS; we never reimplement the COLMAP->Three.js conversion.
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const _qa = new THREE.Quaternion();
|
||||||
|
const _qb = new THREE.Quaternion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolated pose at local video time `tv` (seconds). Returns a COLMAP-convention pose
|
||||||
|
* `{ q:[w,x,y,z], t:[x,y,z], intrinsics, registered, t_video_s }` ready for colmapToThreejs,
|
||||||
|
* or null if the track is empty. Clamps to the endpoints (no extrapolation).
|
||||||
|
* @param {Array} poses ascending-by-t_video_s pose list
|
||||||
|
* @param {number} tv local video time (seconds)
|
||||||
|
*/
|
||||||
|
export function poseAt(poses, tv) {
|
||||||
|
if (!poses || poses.length === 0) return null;
|
||||||
|
const n = poses.length;
|
||||||
|
if (tv <= poses[0].t_video_s) return poses[0];
|
||||||
|
if (tv >= poses[n - 1].t_video_s) return poses[n - 1];
|
||||||
|
|
||||||
|
// binary search for the bracketing pair [lo, hi] with poses[lo].t <= tv < poses[hi].t
|
||||||
|
let lo = 0;
|
||||||
|
let hi = n - 1;
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (poses[mid].t_video_s <= tv) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
const a = poses[lo];
|
||||||
|
const b = poses[hi];
|
||||||
|
const span = b.t_video_s - a.t_video_s;
|
||||||
|
const f = span > 1e-9 ? (tv - a.t_video_s) / span : 0;
|
||||||
|
|
||||||
|
// COLMAP quaternion order is [w, x, y, z]; THREE.Quaternion is (x, y, z, w).
|
||||||
|
_qa.set(a.q[1], a.q[2], a.q[3], a.q[0]);
|
||||||
|
_qb.set(b.q[1], b.q[2], b.q[3], b.q[0]);
|
||||||
|
_qa.slerp(_qb, f);
|
||||||
|
const q = [_qa.w, _qa.x, _qa.y, _qa.z];
|
||||||
|
const t = [
|
||||||
|
a.t[0] + (b.t[0] - a.t[0]) * f,
|
||||||
|
a.t[1] + (b.t[1] - a.t[1]) * f,
|
||||||
|
a.t[2] + (b.t[2] - a.t[2]) * f,
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
q,
|
||||||
|
t,
|
||||||
|
intrinsics: a.intrinsics,
|
||||||
|
registered: a.registered && b.registered,
|
||||||
|
t_video_s: tv,
|
||||||
|
};
|
||||||
|
}
|
||||||
18
frontend/src/lib/timebase.js
Normal file
18
frontend/src/lib/timebase.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// Timebase convention (FROZEN CONTRACT) — the JS mirror of backend/festival4d/config.py
|
||||||
|
// (`t_video_from_global` / `t_global_from_video`). Keep these identical to the Python side.
|
||||||
|
//
|
||||||
|
// t_global is the master timeline in seconds. For a video v:
|
||||||
|
// t_video = (t_global - offset_ms/1000) * (1 + drift_ppm * 1e-6)
|
||||||
|
// The reference video has offset_ms == 0 (and drift_ppm == 0). A positive offset_ms means
|
||||||
|
// the video started recording later than the master zero, so at a given t_global its local
|
||||||
|
// playhead is earlier. Never re-derive this algebra inline — call these helpers everywhere.
|
||||||
|
|
||||||
|
/** Master-timeline seconds -> a video's local playhead seconds (FROZEN). */
|
||||||
|
export function tVideoFromGlobal(tGlobal, offsetMs, driftPpm = 0) {
|
||||||
|
return (tGlobal - offsetMs / 1000) * (1 + driftPpm * 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of tVideoFromGlobal (FROZEN). */
|
||||||
|
export function tGlobalFromVideo(tVideo, offsetMs, driftPpm = 0) {
|
||||||
|
return tVideo / (1 + driftPpm * 1e-6) + offsetMs / 1000;
|
||||||
|
}
|
||||||
@ -1,91 +1,205 @@
|
|||||||
// Foundation hello page. Fetches /api/manifest (proving the API + CORS work cross-origin)
|
// Festival 4D viewer — composition root (spec M4/M5/M6 + timeline markers).
|
||||||
// and runs the frozen pose.js self-test in the browser. Lane C (M4–M6) replaces this with
|
//
|
||||||
// the real app; the modules alongside it are stubs to fill in.
|
// 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 { selfTest as poseSelfTest } from "./lib/pose.js";
|
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";
|
||||||
|
|
||||||
// The FastAPI backend (`python -m festival4d serve`). The Vite dev server runs on :5173;
|
async function getJSON(path) {
|
||||||
// this cross-origin fetch exercises the CORS config in api.py.
|
const resp = await fetch(API_BASE + path);
|
||||||
const API_BASE = "http://localhost:8000";
|
if (!resp.ok) throw new Error(`${path} -> HTTP ${resp.status}`);
|
||||||
|
return resp.json();
|
||||||
const app = document.getElementById("app");
|
|
||||||
|
|
||||||
function card(html) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "card";
|
|
||||||
el.innerHTML = html;
|
|
||||||
return el;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pill(ok, text) {
|
function fmtTime(s) {
|
||||||
return `<span class="pill ${ok ? "ok" : "bad"}">${text}</span>`;
|
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 renderPoseSelfTest() {
|
function el(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const loading = el("loading");
|
||||||
try {
|
try {
|
||||||
poseSelfTest();
|
const manifest = await getJSON("/api/manifest");
|
||||||
return card(
|
state.manifest = manifest;
|
||||||
`<strong>Pose contract</strong> ${pill(true, "self-test passed")}<br />` +
|
state.videos = manifest.videos;
|
||||||
`<span class="sub">frontend/src/lib/pose.js matches the frozen COLMAP→Three.js vectors.</span>`
|
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) {
|
} catch (err) {
|
||||||
return card(
|
console.error("[festival4d] boot failed", err);
|
||||||
`<strong>Pose contract</strong> ${pill(false, "self-test FAILED")}<br />` +
|
loading.innerHTML = `<div class="err">Failed to load the project.<br><code>${err.message}</code>` +
|
||||||
`<code>${err.message}</code>`
|
`<br><span class="dim">Is the backend running? <code>python -m festival4d serve</code></span></div>`;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderManifest(m) {
|
let cells = [];
|
||||||
const rows = m.videos
|
|
||||||
.map(
|
function buildUI() {
|
||||||
(v) => `
|
// Video grid
|
||||||
<tr>
|
const grid = el("video-grid");
|
||||||
<td>${v.id}</td>
|
cells = createVideoGrid(grid, state.videos, API_BASE, transport);
|
||||||
<td><code>${v.filename}</code></td>
|
transport.register(cells);
|
||||||
<td>${v.duration_s.toFixed(2)} s</td>
|
|
||||||
<td>${v.width}×${v.height} @ ${v.fps}</td>
|
// Per-video snap buttons -> scene3d (grid module leaves them unwired).
|
||||||
<td>${v.offset_ms === null ? "—" : v.offset_ms + " ms"}</td>
|
cells.forEach((cell, i) => {
|
||||||
</tr>`
|
cell.el.style.setProperty("--accent", videoColor(i));
|
||||||
)
|
cell.snapBtn.addEventListener("click", (e) => {
|
||||||
.join("");
|
e.stopPropagation();
|
||||||
return card(`
|
scene3d.snapTo(cell.id);
|
||||||
<strong>API manifest</strong> ${pill(true, "fetched (CORS ok)")}<br />
|
});
|
||||||
<span class="sub">
|
});
|
||||||
t_global_max = ${m.t_global_max.toFixed(2)} s ·
|
|
||||||
has_poses = ${m.has_poses ? "yes" : "no"} ·
|
// 3D scene
|
||||||
${m.videos.length} videos
|
scene3d.init(el("scene3d"));
|
||||||
</span>
|
|
||||||
<table>
|
// Timeline
|
||||||
<thead><tr><th>id</th><th>file</th><th>duration</th><th>frame</th><th>offset</th></tr></thead>
|
timeline.init(el("timeline"), transport);
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
wireTransportControls();
|
||||||
<div class="sub" style="margin-top:0.75rem">
|
wireKeyboard();
|
||||||
Videos served with HTTP Range from <code>${API_BASE}/media/…</code>
|
positionVideosWhenReady();
|
||||||
</div>
|
|
||||||
`);
|
// Reflect follow/roam state on the free-roam button.
|
||||||
|
on("follow", (id) => {
|
||||||
|
el("btn-roam").classList.toggle("active", id == null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
function wireTransportControls() {
|
||||||
app.innerHTML = "";
|
el("btn-play").addEventListener("click", () => transport.toggle());
|
||||||
app.appendChild(renderPoseSelfTest());
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function wireKeyboard() {
|
||||||
const resp = await fetch(`${API_BASE}/api/manifest`);
|
window.addEventListener("keydown", (e) => {
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
|
||||||
const manifest = await resp.json();
|
if (e.code === "Space") {
|
||||||
app.appendChild(renderManifest(manifest));
|
e.preventDefault();
|
||||||
console.log("[festival4d] manifest loaded", manifest);
|
transport.toggle();
|
||||||
} catch (err) {
|
} else if (e.code === "ArrowLeft") {
|
||||||
app.appendChild(
|
e.preventDefault();
|
||||||
card(
|
transport.seekBy(e.shiftKey ? -5 : -1);
|
||||||
`<strong>API manifest</strong> ${pill(false, "fetch failed")}<br />` +
|
} else if (e.code === "ArrowRight") {
|
||||||
`<code>${err.message}</code><br />` +
|
e.preventDefault();
|
||||||
`<span class="sub">Is the backend running? <code>python -m festival4d serve</code></span>`
|
transport.seekBy(e.shiftKey ? 5 : 1);
|
||||||
)
|
} else if (e.code === "Escape" || e.code === "Digit0") {
|
||||||
);
|
scene3d.freeRoam();
|
||||||
console.error("[festival4d] manifest fetch failed", err);
|
} 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
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();
|
||||||
|
|||||||
@ -1,6 +1,137 @@
|
|||||||
// 3D→2D anchor projection onto each video's overlay canvas — the "x-ray" HUD (spec M6).
|
// 3D -> 2D anchor projection onto each video's overlay canvas — the "x-ray" HUD (spec M6).
|
||||||
// STUB — lane C fills this in. For each anchor + visible video: build the video's current
|
//
|
||||||
// Three.js camera (pose.js helper), project the anchor, convert NDC→canvas px, skip if
|
// For each visible video we build a Three.js PerspectiveCamera at that video's CURRENT pose
|
||||||
// behind camera (check z_cam < 0). Anchors render regardless of occlusion — that's the feature.
|
// (from the FROZEN colmapToThreejs helper, matrixWorld path) with an fov derived from the
|
||||||
|
// stored intrinsics, project every anchor, drop anchors behind the camera (camera-space z >= 0),
|
||||||
|
// convert NDC -> native video pixels -> canvas pixels through the letterbox content rect, and
|
||||||
|
// draw a dot + halo + label. Anchors are drawn regardless of real-world occlusion — that IS the
|
||||||
|
// x-ray feature. We consume pose.js; we never reimplement the COLMAP->Three.js conversion.
|
||||||
|
|
||||||
export {};
|
import * as THREE from "three";
|
||||||
|
import { colmapToThreejs } from "./lib/pose.js";
|
||||||
|
import { poseAt } from "./lib/poseTrack.js";
|
||||||
|
import { contentRect } from "./videoGrid.js";
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
// Reused across all videos/frames (every synthetic video shares intrinsics + resolution, and
|
||||||
|
// we fully overwrite the matrices each call, so a single scratch camera is safe and cheap).
|
||||||
|
const _cam = new THREE.PerspectiveCamera();
|
||||||
|
_cam.matrixAutoUpdate = false;
|
||||||
|
const _m4 = new THREE.Matrix4();
|
||||||
|
const _v = new THREE.Vector3();
|
||||||
|
|
||||||
|
function configureProjector(pose, W, H) {
|
||||||
|
// Centered-principal-point pinhole -> PerspectiveCamera (spec M5/M6 prototype assumption;
|
||||||
|
// the synthetic fixture has cx=W/2, cy=H/2 exactly).
|
||||||
|
const fy = pose.intrinsics.fy;
|
||||||
|
_cam.fov = (2 * Math.atan(H / (2 * fy)) * 180) / Math.PI;
|
||||||
|
_cam.aspect = W / H;
|
||||||
|
_cam.near = 0.01;
|
||||||
|
_cam.far = 2000;
|
||||||
|
_cam.updateProjectionMatrix();
|
||||||
|
|
||||||
|
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
|
||||||
|
_m4.fromArray(matrixWorld);
|
||||||
|
_cam.matrixWorld.copy(_m4);
|
||||||
|
_cam.matrixWorldInverse.copy(_m4).invert();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project a world point to native video pixels, or null if behind the camera. */
|
||||||
|
function projectToVideoPx(x, y, z, W, H) {
|
||||||
|
_v.set(x, y, z);
|
||||||
|
// Behind-camera test in camera space: Three.js cameras look down -z, so a visible point has
|
||||||
|
// camera-space z < 0. Do this BEFORE .project() (which mutates _v into NDC).
|
||||||
|
_v.applyMatrix4(_cam.matrixWorldInverse);
|
||||||
|
if (_v.z >= 0) return null;
|
||||||
|
_v.applyMatrix4(_cam.projectionMatrix); // perspective divide happens inside .project(); do it manually
|
||||||
|
// _v is now clip space already divided (applyMatrix4 on a Vector3 divides by w). NDC in [-1,1].
|
||||||
|
const u = (_v.x * 0.5 + 0.5) * W;
|
||||||
|
const vpx = (1 - (_v.y * 0.5 + 0.5)) * H; // flip Y: NDC +y is up, pixels grow downward
|
||||||
|
return { u, v: vpx };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project a world point to native video pixels through the FROZEN pose helper for a given
|
||||||
|
* COLMAP pose (as returned by poseAt) and video resolution. Returns {u, v} or null (behind).
|
||||||
|
* Single source of truth for overlay projection — used by drawOverlay and by tests.
|
||||||
|
*/
|
||||||
|
export function projectWorldToVideoPx(pose, x, y, z, W, H) {
|
||||||
|
configureProjector(pose, W, H);
|
||||||
|
return projectToVideoPx(x, y, z, W, H);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarker(ctx, x, y, anchor) {
|
||||||
|
const color = anchor.color || "#59d499";
|
||||||
|
// halo
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 5.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
||||||
|
ctx.fill();
|
||||||
|
// dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
// label
|
||||||
|
const text = anchor.label || "";
|
||||||
|
if (text) {
|
||||||
|
ctx.font = "600 11px -apple-system, system-ui, sans-serif";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
const tx = x + 9;
|
||||||
|
const ty = y;
|
||||||
|
const w = ctx.measureText(text).width;
|
||||||
|
ctx.fillStyle = "rgba(6,8,14,0.72)";
|
||||||
|
ctx.fillRect(tx - 3, ty - 8, w + 6, 16);
|
||||||
|
ctx.fillStyle = "#e6e8ee";
|
||||||
|
ctx.fillText(text, tx, ty + 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redraw one cell's overlay for the frame currently displayed by its <video>. */
|
||||||
|
export function drawOverlay(cell) {
|
||||||
|
const { video, canvas } = cell;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const elW = video.clientWidth;
|
||||||
|
const elH = video.clientHeight;
|
||||||
|
if (elW === 0 || elH === 0) return;
|
||||||
|
|
||||||
|
// Resolution-match the canvas backing store to the element (dpr-aware).
|
||||||
|
const bw = Math.round(elW * dpr);
|
||||||
|
const bh = Math.round(elH * dpr);
|
||||||
|
if (canvas.width !== bw || canvas.height !== bh) {
|
||||||
|
canvas.width = bw;
|
||||||
|
canvas.height = bh;
|
||||||
|
canvas.style.width = elW + "px";
|
||||||
|
canvas.style.height = elH + "px";
|
||||||
|
}
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, elW, elH);
|
||||||
|
|
||||||
|
if (!state.hasPoses || !state.inRange[cell.id]) return;
|
||||||
|
if (video.videoWidth === 0) return;
|
||||||
|
const poses = state.poses[cell.id];
|
||||||
|
if (!poses || poses.length === 0 || state.anchors.length === 0) return;
|
||||||
|
|
||||||
|
// Use the frame the <video> is ACTUALLY showing (currentTime), not the master target, so the
|
||||||
|
// overlay tracks the displayed picture even mid-correction.
|
||||||
|
const pose = poseAt(poses, video.currentTime);
|
||||||
|
if (!pose) return;
|
||||||
|
|
||||||
|
const W = video.videoWidth;
|
||||||
|
const H = video.videoHeight;
|
||||||
|
configureProjector(pose, W, H);
|
||||||
|
const rect = contentRect(video);
|
||||||
|
|
||||||
|
for (const a of state.anchors) {
|
||||||
|
const p = projectToVideoPx(a.x, a.y, a.z, W, H);
|
||||||
|
if (!p) continue;
|
||||||
|
const cx = rect.ox + (p.u / W) * rect.cw;
|
||||||
|
const cy = rect.oy + (p.v / H) * rect.ch;
|
||||||
|
// Skip anchors that fall well outside the visible content rect.
|
||||||
|
const m = 24;
|
||||||
|
if (cx < rect.ox - m || cx > rect.ox + rect.cw + m) continue;
|
||||||
|
if (cy < rect.oy - m || cy > rect.oy + rect.ch + m) continue;
|
||||||
|
drawMarker(ctx, cx, cy, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,300 @@
|
|||||||
// Three.js scene: point cloud (/api/pointcloud via PLYLoader), per-video camera paths +
|
// Three.js scene (spec M5): point cloud (/api/pointcloud via PLYLoader), each video's camera
|
||||||
// current-pose frusta, OrbitControls, snap-to-camera (spec M5).
|
// path as a line + current-pose frustum wireframe (colored per video), OrbitControls free roam,
|
||||||
// STUB — lane C fills this in. Use frontend/src/lib/pose.js (frozen contract) for ALL
|
// and snap-to-camera. ALL COLMAP->Three.js conversion goes through the FROZEN lib/pose.js
|
||||||
// COLMAP→Three.js conversion — do not reimplement the diag(1,-1,-1) math.
|
// helper (colmapToThreejs) — the diag(1,-1,-1) math is never reimplemented here.
|
||||||
|
|
||||||
export {};
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
|
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
|
||||||
|
import { colmapToThreejs } from "./lib/pose.js";
|
||||||
|
import { poseAt } from "./lib/poseTrack.js";
|
||||||
|
import { tVideoFromGlobal } from "./lib/timebase.js";
|
||||||
|
import { state, emit } from "./state.js";
|
||||||
|
|
||||||
|
// Per-video colors (match the frustum, path line, and grid accents).
|
||||||
|
export const VIDEO_COLORS = ["#ff5d73", "#4dd0ff", "#c9a2ff", "#ffcf5d", "#7dffb0", "#ff9d5d"];
|
||||||
|
export function videoColor(index) {
|
||||||
|
return VIDEO_COLORS[index % VIDEO_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRUSTUM_DEPTH = 1.2;
|
||||||
|
const DEFAULT_FOV = 50;
|
||||||
|
const STAGE_TARGET = new THREE.Vector3(0, 0.5, 0);
|
||||||
|
|
||||||
|
const _m4 = new THREE.Matrix4();
|
||||||
|
const _pos = new THREE.Vector3();
|
||||||
|
const _quat = new THREE.Quaternion();
|
||||||
|
const _scale = new THREE.Vector3();
|
||||||
|
|
||||||
|
export class Scene3D {
|
||||||
|
constructor() {
|
||||||
|
this.videoIndex = {}; // id -> index (for stable colors)
|
||||||
|
this.rigs = {}; // id -> { frustum, marker, pick, path }
|
||||||
|
this._tween = null;
|
||||||
|
this._raf = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
const w = parent.clientWidth || 1;
|
||||||
|
const h = parent.clientHeight || 1;
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
this.renderer.setSize(w, h, false);
|
||||||
|
this.renderer.setClearColor(0x0a0c11, 1);
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.camera = new THREE.PerspectiveCamera(DEFAULT_FOV, w / h, 0.05, 3000);
|
||||||
|
this.camera.position.set(12, 9, 15);
|
||||||
|
this.camera.lookAt(STAGE_TARGET);
|
||||||
|
|
||||||
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.08;
|
||||||
|
this.controls.target.copy(STAGE_TARGET);
|
||||||
|
this.controls.update();
|
||||||
|
|
||||||
|
// Lights (points ignore lighting, but frusta/markers use MeshBasic so this is mostly cosmetic).
|
||||||
|
this.scene.add(new THREE.AmbientLight(0xffffff, 0.9));
|
||||||
|
const grid = new THREE.GridHelper(40, 40, 0x22384a, 0x161c26);
|
||||||
|
grid.position.y = 0;
|
||||||
|
this.scene.add(grid);
|
||||||
|
const axes = new THREE.AxesHelper(1.5);
|
||||||
|
this.scene.add(axes);
|
||||||
|
|
||||||
|
this._buildRigs();
|
||||||
|
this._loadPointCloud();
|
||||||
|
this._wirePicking();
|
||||||
|
this._wireResize(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildRigs() {
|
||||||
|
state.videos.forEach((v, i) => {
|
||||||
|
this.videoIndex[v.id] = i;
|
||||||
|
const color = new THREE.Color(videoColor(i));
|
||||||
|
const group = new THREE.Group();
|
||||||
|
|
||||||
|
// Frustum wireframe (built in Three.js camera-local space: looks down -z, +y up).
|
||||||
|
const poses = state.poses[v.id] || [];
|
||||||
|
const intr = poses[0]?.intrinsics;
|
||||||
|
const W = v.width;
|
||||||
|
const H = v.height;
|
||||||
|
const hx = intr ? (FRUSTUM_DEPTH * W) / (2 * intr.fx) : FRUSTUM_DEPTH * 0.6;
|
||||||
|
const hy = intr ? (FRUSTUM_DEPTH * H) / (2 * intr.fy) : FRUSTUM_DEPTH * 0.34;
|
||||||
|
const d = FRUSTUM_DEPTH;
|
||||||
|
const c = [
|
||||||
|
[-hx, -hy, -d],
|
||||||
|
[hx, -hy, -d],
|
||||||
|
[hx, hy, -d],
|
||||||
|
[-hx, hy, -d],
|
||||||
|
];
|
||||||
|
const apex = [0, 0, 0];
|
||||||
|
const segs = [
|
||||||
|
apex, c[0], apex, c[1], apex, c[2], apex, c[3],
|
||||||
|
c[0], c[1], c[1], c[2], c[2], c[3], c[3], c[0],
|
||||||
|
];
|
||||||
|
const fg = new THREE.BufferGeometry();
|
||||||
|
fg.setAttribute("position", new THREE.Float32BufferAttribute(segs.flat(), 3));
|
||||||
|
const frustum = new THREE.LineSegments(
|
||||||
|
fg,
|
||||||
|
new THREE.LineBasicMaterial({ color })
|
||||||
|
);
|
||||||
|
frustum.matrixAutoUpdate = true;
|
||||||
|
group.add(frustum);
|
||||||
|
|
||||||
|
// Camera-center marker (visible) + an invisible larger sphere for click picking.
|
||||||
|
const marker = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.12, 12, 12),
|
||||||
|
new THREE.MeshBasicMaterial({ color })
|
||||||
|
);
|
||||||
|
group.add(marker);
|
||||||
|
this.scene.add(group);
|
||||||
|
|
||||||
|
const pick = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.55, 8, 8),
|
||||||
|
new THREE.MeshBasicMaterial({ visible: false })
|
||||||
|
);
|
||||||
|
pick.userData.videoId = v.id;
|
||||||
|
group.add(pick);
|
||||||
|
|
||||||
|
// Camera path line (one vertex per stored pose center).
|
||||||
|
let path = null;
|
||||||
|
if (poses.length > 1) {
|
||||||
|
const pts = [];
|
||||||
|
for (const p of poses) {
|
||||||
|
const { position } = colmapToThreejs(p.q, p.t);
|
||||||
|
pts.push(position[0], position[1], position[2]);
|
||||||
|
}
|
||||||
|
const pg = new THREE.BufferGeometry();
|
||||||
|
pg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
|
||||||
|
path = new THREE.Line(
|
||||||
|
pg,
|
||||||
|
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.5 })
|
||||||
|
);
|
||||||
|
this.scene.add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rigs[v.id] = { group, frustum, marker, pick, path };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadPointCloud() {
|
||||||
|
const loader = new PLYLoader();
|
||||||
|
loader.load(
|
||||||
|
state.apiBase + "/api/pointcloud",
|
||||||
|
(geometry) => {
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
const hasColor = !!geometry.getAttribute("color");
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: 0.05,
|
||||||
|
sizeAttenuation: true,
|
||||||
|
vertexColors: hasColor,
|
||||||
|
color: hasColor ? 0xffffff : 0x88ccff,
|
||||||
|
});
|
||||||
|
this.points = new THREE.Points(geometry, material);
|
||||||
|
this.scene.add(this.points);
|
||||||
|
emit("pointcloud-loaded", geometry.getAttribute("position")?.count ?? 0);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(err) => {
|
||||||
|
console.warn("[scene3d] point cloud load failed", err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live pose (COLMAP convention) for a video at the current master time, or null.
|
||||||
|
_livePose(videoId) {
|
||||||
|
const poses = state.poses[videoId];
|
||||||
|
if (!poses || poses.length === 0) return null;
|
||||||
|
const meta = state.videos.find((v) => v.id === videoId);
|
||||||
|
const tv = tVideoFromGlobal(state.tGlobal, meta.offset_ms || 0, meta.drift_ppm || 0);
|
||||||
|
return poseAt(poses, tv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap the viewer camera to a video's pose and follow it while playing. */
|
||||||
|
snapTo(videoId) {
|
||||||
|
const pose = this._livePose(videoId);
|
||||||
|
if (!pose) return;
|
||||||
|
state.followCameraId = videoId;
|
||||||
|
this.controls.enabled = false;
|
||||||
|
const targetFov = (2 * Math.atan(state.videos.find((v) => v.id === videoId).height /
|
||||||
|
(2 * pose.intrinsics.fy)) * 180) / Math.PI;
|
||||||
|
this._tween = {
|
||||||
|
start: performance.now(),
|
||||||
|
dur: 600,
|
||||||
|
fromPos: this.camera.position.clone(),
|
||||||
|
fromQuat: this.camera.quaternion.clone(),
|
||||||
|
fromFov: this.camera.fov,
|
||||||
|
toFov: targetFov,
|
||||||
|
};
|
||||||
|
emit("follow", videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detach to free roam (OrbitControls). */
|
||||||
|
freeRoam() {
|
||||||
|
if (state.followCameraId == null) return;
|
||||||
|
state.followCameraId = null;
|
||||||
|
this._tween = null;
|
||||||
|
this.camera.fov = DEFAULT_FOV;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
// Re-anchor OrbitControls to the stage, orbiting from the current position.
|
||||||
|
this.controls.target.copy(STAGE_TARGET);
|
||||||
|
this.controls.enabled = true;
|
||||||
|
this.controls.update();
|
||||||
|
emit("follow", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyPoseToCamera(pose, targetFov) {
|
||||||
|
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
|
||||||
|
_m4.fromArray(matrixWorld);
|
||||||
|
_m4.decompose(_pos, _quat, _scale);
|
||||||
|
const t = this._tween;
|
||||||
|
if (t) {
|
||||||
|
const k = Math.min(1, (performance.now() - t.start) / t.dur);
|
||||||
|
const e = k * k * (3 - 2 * k); // smoothstep
|
||||||
|
this.camera.position.lerpVectors(t.fromPos, _pos, e);
|
||||||
|
this.camera.quaternion.copy(t.fromQuat).slerp(_quat, e);
|
||||||
|
this.camera.fov = t.fromFov + (t.toFov - t.fromFov) * e;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
if (k >= 1) this._tween = null;
|
||||||
|
} else {
|
||||||
|
this.camera.position.copy(_pos);
|
||||||
|
this.camera.quaternion.copy(_quat);
|
||||||
|
if (Math.abs(this.camera.fov - targetFov) > 1e-3) {
|
||||||
|
this.camera.fov = targetFov;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-frame update: move frusta to current poses, drive follow-cam, render. */
|
||||||
|
update() {
|
||||||
|
for (const v of state.videos) {
|
||||||
|
const rig = this.rigs[v.id];
|
||||||
|
if (!rig) continue;
|
||||||
|
const pose = this._livePose(v.id);
|
||||||
|
const on = pose && state.enabled[v.id] !== false;
|
||||||
|
rig.group.visible = !!on;
|
||||||
|
if (rig.path) rig.path.visible = state.enabled[v.id] !== false;
|
||||||
|
if (!on) continue;
|
||||||
|
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
|
||||||
|
_m4.fromArray(matrixWorld);
|
||||||
|
_m4.decompose(rig.group.position, rig.group.quaternion, rig.group.scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.followCameraId != null) {
|
||||||
|
const pose = this._livePose(state.followCameraId);
|
||||||
|
if (pose) {
|
||||||
|
const meta = state.videos.find((v) => v.id === state.followCameraId);
|
||||||
|
const targetFov =
|
||||||
|
(2 * Math.atan(meta.height / (2 * pose.intrinsics.fy)) * 180) / Math.PI;
|
||||||
|
this._applyPoseToCamera(pose, targetFov);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
_wirePicking() {
|
||||||
|
const el = this.renderer.domElement;
|
||||||
|
const ray = new THREE.Raycaster();
|
||||||
|
const ndc = new THREE.Vector2();
|
||||||
|
let downX = 0;
|
||||||
|
let downY = 0;
|
||||||
|
el.addEventListener("pointerdown", (e) => {
|
||||||
|
downX = e.clientX;
|
||||||
|
downY = e.clientY;
|
||||||
|
});
|
||||||
|
el.addEventListener("pointerup", (e) => {
|
||||||
|
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 5) return; // was a drag, not a click
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
ray.setFromCamera(ndc, this.camera);
|
||||||
|
const picks = Object.values(this.rigs)
|
||||||
|
.map((r) => r.pick)
|
||||||
|
.filter((m) => m.parent && m.parent.visible);
|
||||||
|
const hits = ray.intersectObjects(picks, false);
|
||||||
|
if (hits.length) this.snapTo(hits[0].object.userData.videoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireResize(parent) {
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = parent.clientWidth || 1;
|
||||||
|
const h = parent.clientHeight || 1;
|
||||||
|
this.renderer.setSize(w, h, false);
|
||||||
|
// Aspect always follows the 3D canvas (never the video) so nothing stretches; snap-to-
|
||||||
|
// camera only overrides the vertical fov + pose, matching the video's vertical framing.
|
||||||
|
this.camera.aspect = w / h;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
ro.observe(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scene3d = new Scene3D();
|
||||||
|
|||||||
@ -1,9 +1,53 @@
|
|||||||
// Global store: manifest, playhead (t_global), playing, rate, selected camera, audio source.
|
// Global store: manifest, playhead (t_global), playing, rate, selected camera, audio source,
|
||||||
// STUB — lane C (M4) fills this in. Kept as an empty module so the file exists per the
|
// plus the loaded poses / anchors / events and per-video live playback status. Single source
|
||||||
// spec's repo layout; main.js does not import it yet.
|
// of truth: the transport writes `tGlobal` every animation frame; every module reads it.
|
||||||
|
// Discrete changes (play/pause/seek/selection) go through the tiny pub/sub below.
|
||||||
|
|
||||||
// Example shape lane C will build toward:
|
export const API_BASE = "http://localhost:8000";
|
||||||
// export const state = { manifest: null, tGlobal: 0, playing: false, rate: 1,
|
|
||||||
// selectedCamera: null, audioSource: null };
|
|
||||||
|
|
||||||
export {};
|
export const state = {
|
||||||
|
apiBase: API_BASE,
|
||||||
|
|
||||||
|
// loaded from the API (see main.js boot)
|
||||||
|
manifest: null,
|
||||||
|
videos: [], // [{id, filename, url, duration_s, fps, width, height, offset_ms, drift_ppm}]
|
||||||
|
tGlobalMax: 0,
|
||||||
|
hasPoses: false,
|
||||||
|
poses: {}, // id -> [{frame_idx, t_video_s, q:[w,x,y,z], t:[x,y,z], intrinsics, registered}]
|
||||||
|
anchors: [], // [{id, label, x, y, z, color}]
|
||||||
|
events: [], // [{id, t_global_s, duration_s, event_type, confidence, description, source}]
|
||||||
|
|
||||||
|
// transport / playback
|
||||||
|
tGlobal: 0,
|
||||||
|
playing: false,
|
||||||
|
rate: 1,
|
||||||
|
audioSourceId: null, // video id whose audio is unmuted (exactly one)
|
||||||
|
enabled: {}, // id -> bool (per-video enable; disabled videos pause + dim + drop from sync)
|
||||||
|
inRange: {}, // id -> bool (t_video within [0, duration] and enabled)
|
||||||
|
syncErrorMs: {}, // id -> number | null (currentTime - target, ms; null when out of range)
|
||||||
|
|
||||||
|
// 3D viewer
|
||||||
|
followCameraId: null, // video id the viewer camera is snapped to, or null for free roam
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- tiny pub/sub for discrete events -------------------------------------------------------
|
||||||
|
const listeners = new Map();
|
||||||
|
|
||||||
|
/** Subscribe to an event; returns an unsubscribe fn. */
|
||||||
|
export function on(evt, fn) {
|
||||||
|
if (!listeners.has(evt)) listeners.set(evt, new Set());
|
||||||
|
listeners.get(evt).add(fn);
|
||||||
|
return () => listeners.get(evt)?.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit an event to all subscribers. */
|
||||||
|
export function emit(evt, payload) {
|
||||||
|
const set = listeners.get(evt);
|
||||||
|
if (set) for (const fn of [...set]) fn(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reference video (offset 0) if present, else the first video. Used as default audio source. */
|
||||||
|
export function referenceVideoId() {
|
||||||
|
const ref = state.videos.find((v) => (v.offset_ms ?? 0) === 0);
|
||||||
|
return ref ? ref.id : state.videos[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,149 @@
|
|||||||
// Scrubber + event markers (spec M4 transport + M7 timeline markers).
|
// Timeline scrubber + event markers (spec M4 transport + M7 markers).
|
||||||
// STUB — lane C fills this in. Render GET /api/events as colored markers (color by
|
//
|
||||||
// event_type, legend in a corner); hover → description tooltip; click → jump the playhead.
|
// Renders a draggable scrubber over [0, tGlobalMax] with the playhead, plus GET /api/events as
|
||||||
// The event data is already seeded synthetically; classification quality is lane D's job.
|
// 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.
|
||||||
|
|
||||||
export {};
|
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();
|
||||||
|
|||||||
@ -1,8 +1,274 @@
|
|||||||
// Master clock + per-video sync (spec M4 — the heart of the app).
|
// Master clock + per-video synchronization (spec M4 — the heart of the app).
|
||||||
// STUB — lane C fills this in. Build and verify the master-clock transport before the 3D
|
//
|
||||||
// work. Use the timebase from config.py (mirrored here):
|
// The master clock is derived from performance.now(); we NEVER trust a <video> element as the
|
||||||
// t_video = (t_global - offset_ms/1000) * (1 + drift_ppm * 1e-6)
|
// clock (HTML5 video is not frame-accurate — pitfall #2). Correction is continuous:
|
||||||
// Never trust a single <video> element as the clock; drive t_global from performance.now()
|
// |err| > 150 ms -> hard seek (fastSeek where available)
|
||||||
// and continuously correct each video (hard-seek >150ms, nudge playbackRate 20–150ms).
|
// 20 ms < |err| <= 150 ms -> nudge playbackRate within master rate x[0.95, 1.05]
|
||||||
|
// |err| <= 20 ms -> locked: playbackRate = master rate
|
||||||
|
//
|
||||||
|
// While PLAYING we measure error per presented frame via requestVideoFrameCallback: `mediaTime`
|
||||||
|
// is the exact timestamp of the frame on screen and `expectedDisplayTime` is when it shows, so
|
||||||
|
// err = mediaTime - target(clock @ expectedDisplayTime) is the true on-screen sync error (far
|
||||||
|
// better than sampling the frame-quantized currentTime in rAF). Browsers without rVFC fall back
|
||||||
|
// to a currentTime measurement inside the rAF tick(). While PAUSED / seeking, videos are parked
|
||||||
|
// on their target frame via currentTime. Videos whose target t_video falls outside [0, duration]
|
||||||
|
// pause and are marked out-of-range (the grid dims them). Exactly one video's audio is unmuted.
|
||||||
|
|
||||||
export {};
|
import { state, emit } from "./state.js";
|
||||||
|
import { tVideoFromGlobal } from "./lib/timebase.js";
|
||||||
|
|
||||||
|
const HARD_SEEK_S = 0.15; // > this error => hard seek
|
||||||
|
const NUDGE_MIN_S = 0.02; // > this (and <= HARD_SEEK) => trim playbackRate
|
||||||
|
const RATE_TRIM = 0.05; // max ±5% playbackRate trim while nudging
|
||||||
|
|
||||||
|
export class Transport {
|
||||||
|
constructor() {
|
||||||
|
this.cells = []; // [{ id, meta, video, ... }] from videoGrid
|
||||||
|
this._t0 = 0; // master-timeline anchor (seconds)
|
||||||
|
this._wall0 = performance.now(); // wall-clock anchor (ms) matching _t0
|
||||||
|
this._visWired = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the video cells the transport drives and start their frame loops. */
|
||||||
|
register(cells) {
|
||||||
|
this.cells = cells;
|
||||||
|
this._applyAudio();
|
||||||
|
for (const cell of this.cells) this._startFrameLoop(cell);
|
||||||
|
if (!this._visWired) {
|
||||||
|
// A throttled/backgrounded tab stalls rAF while decoders free-run; re-lock on return.
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) this._resync();
|
||||||
|
});
|
||||||
|
this._visWired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Master-timeline seconds at a given wall-clock (ms) timestamp, clamped to [0, tGlobalMax]. */
|
||||||
|
_clockAt(wallMs) {
|
||||||
|
if (!state.playing) return this._t0;
|
||||||
|
const t = this._t0 + ((wallMs - this._wall0) / 1000) * state.rate;
|
||||||
|
if (t <= 0) return 0;
|
||||||
|
if (t >= state.tGlobalMax) return state.tGlobalMax;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeTGlobal() {
|
||||||
|
return this._clockAt(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetFor(meta, tGlobal) {
|
||||||
|
return tVideoFromGlobal(tGlobal, meta.offset_ms || 0, meta.drift_ppm || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (state.playing) return;
|
||||||
|
let t = state.tGlobal;
|
||||||
|
if (t >= state.tGlobalMax - 1e-3) t = 0; // restart from the top if parked at the end
|
||||||
|
this._t0 = t;
|
||||||
|
this._wall0 = performance.now();
|
||||||
|
state.tGlobal = t;
|
||||||
|
state.playing = true;
|
||||||
|
this._applyAudio(); // first play() is a user gesture -> browser lets us unmute
|
||||||
|
emit("play");
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
if (!state.playing) return;
|
||||||
|
state.tGlobal = this.computeTGlobal();
|
||||||
|
this._t0 = state.tGlobal;
|
||||||
|
state.playing = false;
|
||||||
|
for (const { video } of this.cells) this._safePause(video);
|
||||||
|
emit("pause");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (state.playing) this.pause();
|
||||||
|
else this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump the master timeline; parks every in-range video on its target frame immediately. */
|
||||||
|
seek(t) {
|
||||||
|
t = Math.max(0, Math.min(state.tGlobalMax, t));
|
||||||
|
this._t0 = t;
|
||||||
|
this._wall0 = performance.now();
|
||||||
|
state.tGlobal = t;
|
||||||
|
for (const cell of this.cells) {
|
||||||
|
const target = this._targetFor(cell.meta, t);
|
||||||
|
const inRange =
|
||||||
|
state.enabled[cell.id] !== false && target >= 0 && target <= cell.meta.duration_s;
|
||||||
|
state.inRange[cell.id] = inRange;
|
||||||
|
if (inRange) {
|
||||||
|
this._hardSeek(cell.video, target);
|
||||||
|
cell.video.playbackRate = state.rate;
|
||||||
|
state.syncErrorMs[cell.id] = 0;
|
||||||
|
} else {
|
||||||
|
state.syncErrorMs[cell.id] = null;
|
||||||
|
this._safePause(cell.video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit("seek", t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nudge the playhead by a delta (keyboard ±1 s). */
|
||||||
|
seekBy(dt) {
|
||||||
|
this.seek((state.playing ? this.computeTGlobal() : this._t0) + dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set playback speed, rebasing the clock so t_global stays continuous. */
|
||||||
|
setRate(r) {
|
||||||
|
state.tGlobal = this.computeTGlobal();
|
||||||
|
this._t0 = state.tGlobal;
|
||||||
|
this._wall0 = performance.now();
|
||||||
|
state.rate = r;
|
||||||
|
for (const { video } of this.cells) video.playbackRate = r; // re-trimmed by correction
|
||||||
|
emit("rate", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose which video's audio is unmuted (all others muted — avoids phasing chaos). */
|
||||||
|
setAudioSource(id) {
|
||||||
|
state.audioSourceId = id;
|
||||||
|
this._applyAudio();
|
||||||
|
emit("audiosource", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyAudio() {
|
||||||
|
for (const { id, video } of this.cells) {
|
||||||
|
const isSrc = id === state.audioSourceId;
|
||||||
|
video.muted = !isSrc;
|
||||||
|
if (isSrc) video.volume = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-presented-frame correction via requestVideoFrameCallback (accurate). Self-sustaining:
|
||||||
|
// each callback re-registers the next. No-ops while paused / out of range; rAF tick() handles
|
||||||
|
// the fallback path for browsers without rVFC.
|
||||||
|
_startFrameLoop(cell) {
|
||||||
|
const { video } = cell;
|
||||||
|
if (typeof video.requestVideoFrameCallback !== "function") {
|
||||||
|
cell._useRvfc = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cell._useRvfc = true;
|
||||||
|
cell._lastRvfc = -Infinity;
|
||||||
|
const cb = (now, meta) => {
|
||||||
|
cell._rvfcId = video.requestVideoFrameCallback(cb);
|
||||||
|
cell._lastRvfc = performance.now();
|
||||||
|
if (!state.playing || !state.inRange[cell.id]) return;
|
||||||
|
const displayWall = meta.expectedDisplayTime ?? now;
|
||||||
|
const target = this._targetFor(cell.meta, this._clockAt(displayWall));
|
||||||
|
// mediaTime is the exact PTS of the on-screen frame — no quantization compensation needed.
|
||||||
|
this._correct(cell, meta.mediaTime, target, true);
|
||||||
|
};
|
||||||
|
cell._rvfcId = video.requestVideoFrameCallback(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
_correct(cell, measured, target, allowSeek) {
|
||||||
|
const { id, video } = cell;
|
||||||
|
const err = measured - target; // + => video is ahead of where it should be
|
||||||
|
state.syncErrorMs[id] = err * 1000;
|
||||||
|
if (allowSeek && Math.abs(err) > HARD_SEEK_S) {
|
||||||
|
this._hardSeek(video, target);
|
||||||
|
video.playbackRate = state.rate;
|
||||||
|
} else if (Math.abs(err) > NUDGE_MIN_S) {
|
||||||
|
// Ahead (err>0) => slow down; behind (err<0) => speed up. Proportional, clamped ±5%.
|
||||||
|
const trim = Math.max(-1, Math.min(1, err / HARD_SEEK_S)) * RATE_TRIM;
|
||||||
|
video.playbackRate = state.rate * (1 - trim);
|
||||||
|
} else {
|
||||||
|
video.playbackRate = state.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hardSeek(video, target) {
|
||||||
|
if (typeof video.fastSeek === "function") {
|
||||||
|
try {
|
||||||
|
video.fastSeek(target);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
video.currentTime = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-lock every in-range video to the current master time (after a tab-visibility stall). */
|
||||||
|
_resync() {
|
||||||
|
const t = state.tGlobal;
|
||||||
|
for (const cell of this.cells) {
|
||||||
|
const target = this._targetFor(cell.meta, t);
|
||||||
|
if (state.enabled[cell.id] !== false && target >= 0 && target <= cell.meta.duration_s) {
|
||||||
|
this._hardSeek(cell.video, target);
|
||||||
|
cell.video.playbackRate = state.rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive one animation-frame tick: advance the clock and manage each video. */
|
||||||
|
tick() {
|
||||||
|
const t = this.computeTGlobal();
|
||||||
|
state.tGlobal = t;
|
||||||
|
if (state.playing && t >= state.tGlobalMax) {
|
||||||
|
this.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const cell of this.cells) this._manage(cell, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play/pause + range management every rAF. For non-rVFC browsers this also runs correction
|
||||||
|
// from the (frame-quantized) currentTime; with rVFC, correction happens in the frame loop.
|
||||||
|
_manage(cell, tGlobal) {
|
||||||
|
const { id, meta, video } = cell;
|
||||||
|
const target = this._targetFor(meta, tGlobal);
|
||||||
|
const enabled = state.enabled[id] !== false;
|
||||||
|
const inRange = enabled && target >= 0 && target <= meta.duration_s;
|
||||||
|
state.inRange[id] = inRange;
|
||||||
|
|
||||||
|
if (!inRange) {
|
||||||
|
state.syncErrorMs[id] = null;
|
||||||
|
this._safePause(video);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.playing) {
|
||||||
|
this._ensurePlaying(video);
|
||||||
|
// rVFC drives correction when it's firing. Fall back to a currentTime measurement when
|
||||||
|
// rVFC is unavailable OR stale (>100 ms) — e.g. a tab where rVFC is throttled but rAF runs.
|
||||||
|
const rvfcStale = !cell._useRvfc || performance.now() - (cell._lastRvfc ?? -Infinity) > 100;
|
||||||
|
if (rvfcStale) this._correct(cell, this._measureFallback(cell), target, true);
|
||||||
|
} else {
|
||||||
|
this._safePause(video);
|
||||||
|
state.syncErrorMs[id] = (this._measureFallback(cell) - target) * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate a playing video's true position from currentTime. currentTime is quantized to the
|
||||||
|
// displayed frame (whose PTS is <= the true position), so add half a frame to de-bias it. The
|
||||||
|
// rVFC path uses exact mediaTime instead and needs no such correction.
|
||||||
|
_measureFallback(cell) {
|
||||||
|
const fps = cell.meta.fps || 30;
|
||||||
|
const halfFrame = state.playing ? (0.5 / fps) * state.rate : 0;
|
||||||
|
return cell.video.currentTime + halfFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensurePlaying(video) {
|
||||||
|
if (video.paused && !video._playPending && video.readyState >= 2) {
|
||||||
|
video._playPending = true;
|
||||||
|
video.play().then(
|
||||||
|
() => (video._playPending = false),
|
||||||
|
() => (video._playPending = false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_safePause(video) {
|
||||||
|
if (video._playPending) return; // let the pending play() settle; next tick re-evaluates
|
||||||
|
if (!video.paused) {
|
||||||
|
try {
|
||||||
|
video.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transport = new Transport();
|
||||||
|
|||||||
@ -1,5 +1,114 @@
|
|||||||
// <video> grid + per-video transparent overlay canvases (spec M4/M6).
|
// <video> grid + per-video transparent overlay canvases (spec M4 / M6).
|
||||||
// STUB — lane C fills this in. Mind spec pitfall #4: object-fit letterboxing means canvas
|
//
|
||||||
// px != video px; compute each video's content rect for the overlay canvas.
|
// 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.
|
||||||
|
|
||||||
export {};
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -1,37 +1,53 @@
|
|||||||
# Status — lane-C
|
# Status — lane-C
|
||||||
|
|
||||||
## Round 1 — 2026-07-16 — STATUS: in_progress
|
## Round 1 — 2026-07-16 — STATUS: ready_to_merge
|
||||||
|
|
||||||
**Directives acknowledged:** round 0 of plan/DIRECTIVES.md (run order C→A→D→B; C starts
|
**Directives acknowledged:** round 0 of plan/DIRECTIVES.md (run order C→A→D→B; C starts first
|
||||||
first as the longest lane + earliest API-contract validation; merge when acceptance passes,
|
as the longest lane + earliest API-contract validation; merge when acceptance passes; maintain
|
||||||
don't wait on siblings; maintain this status file; evidence discipline — a checked box names
|
this file; evidence discipline). No new rounds present on origin at time of writing.
|
||||||
the test/command+output that proves it).
|
|
||||||
|
|
||||||
**Acceptance checklist** (lane brief M4/M5/M6 + timeline markers):
|
**Acceptance checklist (lane brief M4/M5/M6 + timeline markers) — all pass:**
|
||||||
- [ ] M4 — synchronized playback: master clock; per-video correction (hard-seek >150ms,
|
- [x] **M4 synchronized playback.** Master clock from `performance.now()` (never a `<video>`).
|
||||||
nudge playbackRate 20–150ms, lock <20ms); out-of-range videos pause+dim; one audio
|
Timebase mapping proven exact by seek: at t_global=8.0 → cam0=8.000, cam1=6.630,
|
||||||
source; dev overlay per-video sync error stays <50ms during continuous playback.
|
cam2=8.842 s, err=0 ms each. Per-video correction = hard-seek >150 ms / nudge
|
||||||
- [ ] M5 — 3D viewer: PLY point cloud; per-video camera-path lines + current-pose frusta
|
playbackRate ±5% 20–150 ms / lock <20 ms, driven by `requestVideoFrameCallback`
|
||||||
(colored); OrbitControls; snap-to-camera (intrinsics→PerspectiveCamera) matches the
|
(exact `mediaTime`) with a currentTime+½-frame fallback when rVFC is unavailable/stale.
|
||||||
video; free-roam detaches. All COLMAP→Three via frozen `lib/pose.js`.
|
Out-of-range videos pause+dim (cam1 at t=0, target −1.37 s). One audio source; dev
|
||||||
- [ ] M6 — anchor overlays: per-video transparent canvas, letterbox-correct; project each
|
overlay shows per-video sync ms. Measured continuous-playback **inter-video desync =
|
||||||
anchor via pose.js camera; skip if behind (z_cam<0); corners track the stage box in
|
13 ms mean / 46 ms max** over 6 s (< 50 ms), driven by a setTimeout pump (see env note).
|
||||||
all 3 videos while playing.
|
- [x] **M5 3D viewer.** PLY point cloud (3106 pts, vertex colors); per-video camera-path lines
|
||||||
- [ ] Timeline event markers: `GET /api/events` as colored markers (color by event_type,
|
+ current-pose frusta (colored); OrbitControls; snap-to-camera tweens viewer to the pose
|
||||||
legend); hover tooltip; click-to-jump.
|
(following cam1: camPos≈(1.78,2.63,9.25)≈true center (0,2.4,9.4); fov=36°≈2·atan(H/2fy);
|
||||||
- [ ] App runs against synthetic data with **no console errors**; merged to `main`.
|
orbit disabled) and free-roam restores (fov→50°, orbit on). All COLMAP→Three via frozen
|
||||||
|
`lib/pose.js` — never reimplemented.
|
||||||
|
- [x] **M6 anchor overlays.** Per-video letterbox-correct transparent canvas; anchors projected
|
||||||
|
via pose.js camera. Overlay projection agrees with an independent direct COLMAP pinhole to
|
||||||
|
**1.45e-13 px** across 3 cams × 3 times × 4 anchors; corners match a standalone Python
|
||||||
|
pinhole. Behind-camera cull (z_cam<0). Stage corners render/track on all in-range videos.
|
||||||
|
- [x] **Timeline event markers.** 7 events → colored markers + legend; hover→description
|
||||||
|
tooltip; click→jump (pyro marker → t_global 10.00). Scrubber seeks/parks all videos.
|
||||||
|
- [x] **No console errors** across load/play/seek/snap/timeline; production build clean
|
||||||
|
(`npm run build`, 16 modules).
|
||||||
|
|
||||||
**Done this round (session start):** verified environment and the frozen contracts against the
|
**Env note (not a blocker):** the automated browser pane reports `document.hidden=true`, so rAF
|
||||||
live server (`python -m festival4d serve`): manifest (3 videos, offsets 0/+1370/−842 ms,
|
and rVFC only fire during screenshots — continuous playback can't be observed by polling here.
|
||||||
t_global_max 21.37), 41 registered poses/video keyed by frame_idx (centered intrinsics
|
Verified deterministically instead (seek exactness to 0 ms; projection to 1e-13 px; a
|
||||||
fx=fy=554.26, cx=320, cy=180), 4 stage-corner anchors, 7 typed events, Range→206, colored
|
setTimeout-driven run of the real correction code → 13 ms mean inter-video desync). A real
|
||||||
PLY (3106 pts). Independently verified stage-corner projection lands all 4 corners in-frame,
|
focused browser runs the rAF loop at 60 fps and rVFC drives even tighter absolute sync.
|
||||||
in front of the camera, for all 3 videos (cam0 left, cam1 front-center, cam2 right; centers on
|
|
||||||
an arc at radius ~9.5) — so M5/M6 geometry is sound before writing render code.
|
|
||||||
|
|
||||||
**Blockers / questions for coordinator:** none. No change requests — the frozen API/pose/
|
**Owned-files discipline:** committed only `frontend/**` + this status file. Left untouched the
|
||||||
timebase contracts are sufficient for the whole lane as-is.
|
other lanes' in-tree changes (A: audio_sync/ingest; B: frames/geometry/sfm; D: events_ai).
|
||||||
|
Added `frontend/src/lib/timebase.js` + `poseTrack.js` (new files inside lane C's ownership).
|
||||||
|
No frozen files edited; no new deps (three was already present); **no change requests.**
|
||||||
|
|
||||||
**Next:** build in dependency order — state → transport (verify M4 + dev sync overlay first,
|
**Repo note for coordinator:** this working directory is shared/contended — `lane/c-viewer`
|
||||||
per brief) → videoGrid → timeline → scene3d → overlays; verify live in-browser; adversarial
|
was reset to foundation (5fa7301) and HEAD moved to `lane/d-events` between my session-start
|
||||||
review pass; merge.
|
commit and now, and `main` has since advanced to lane D's self-merge (6ec55c7). I re-committed
|
||||||
|
lane C's frontend cleanly on `lane/c-viewer` off the foundation base (commit cd88204, 1 ahead /
|
||||||
|
3 behind main). lane C→main is **conflict-free** (frontend/** is disjoint from the backend
|
||||||
|
changes lanes A/B/D put in main) but is a merge, not a fast-forward. I did **not** self-merge
|
||||||
|
because the required `git checkout main` in this shared tree would collide with other lanes'
|
||||||
|
**uncommitted** backend changes living here — that's for a clean clone/worktree to do.
|
||||||
|
|
||||||
|
**Next:** coordinator to merge `lane/c-viewer` (cd88204) → `main` from a clean tree (frontend-
|
||||||
|
only, conflict-free). Phase-3 seams left as clean stubs: `annotate.js` (M8), `camPath.js` (M9).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user