M8: POST /api/annotations resolves a bbox to a 3D point (ray → two-view
triangulation / nearest-point / centroid-depth) via lane B's geometry
primitives; correction panel + bbox-drawing UI; resolved anchors render live
in overlays and the 3D scene. CR-2 (additive): annotations.event_id +
PATCH /api/events/{id} + db.update_event.
M9: keyframed god's-eye camera paths (Catmull-Rom position + slerp
orientation, export/import), gizmos, path-mode camera hooks.
Verified: 101 backend tests pass (two-view corner recovery err 0.00000);
frontend build clean; live in-browser M8 triangulation (gap 0.21) + M9 path
(camera on keyframe at t) + M4 sync ~16ms. Cleanup: worktrees removed, debris
stash dropped, pristine demo DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
11 KiB
HTML
254 lines
11 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Festival 4D</title>
|
||
<style>
|
||
: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 {
|
||
margin: 0;
|
||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
}
|
||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||
|
||
/* Top bar */
|
||
#topbar {
|
||
display: flex; align-items: baseline; gap: 0.6rem;
|
||
padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--border); flex: 0 0 auto;
|
||
}
|
||
#topbar h1 { margin: 0; font-size: 1.05rem; font-weight: 700; letter-spacing: 0.2px; }
|
||
#topbar .sub { color: var(--dim); font-size: 0.8rem; }
|
||
|
||
/* Stage: 3D on the left, video grid on the right */
|
||
#stage { flex: 1 1 auto; display: flex; min-height: 0; }
|
||
#scene-pane { flex: 1 1 55%; position: relative; min-width: 0; background: #0a0c11; }
|
||
#scene3d { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||
#scene-controls {
|
||
position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; z-index: 5;
|
||
align-items: center; flex-wrap: wrap; max-width: calc(100% - 260px);
|
||
}
|
||
#scene-controls .btn { padding: 0.25rem 0.5rem; font-size: 0.78rem; }
|
||
#key-count { color: var(--accent); font-variant-numeric: tabular-nums; }
|
||
.ctl-sep { width: 1px; height: 18px; background: var(--border); margin: 0 0.15rem; }
|
||
#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); }
|
||
|
||
/* M8 annotation correction panel */
|
||
#annot-panel {
|
||
position: absolute; top: 0.6rem; right: 0.6rem; width: 232px; z-index: 8;
|
||
background: rgba(15,20,32,0.94); border: 1px solid var(--border); border-radius: 9px;
|
||
font-size: 0.78rem; box-shadow: 0 8px 26px rgba(0,0,0,0.5); backdrop-filter: blur(5px);
|
||
}
|
||
.ap-head {
|
||
display: flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.55rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.ap-dot { width: 9px; height: 9px; border-radius: 3px; flex: 0 0 auto; }
|
||
.ap-head strong { flex: 1 1 auto; font-size: 0.8rem; }
|
||
.ap-x {
|
||
background: none; border: none; color: var(--dim); cursor: pointer; font-size: 0.85rem;
|
||
padding: 0 0.2rem;
|
||
}
|
||
.ap-x:hover { color: var(--text); }
|
||
.ap-body { padding: 0.5rem 0.55rem 0.6rem; display: flex; flex-direction: column; gap: 0.45rem; }
|
||
.ap-row { display: flex; align-items: center; justify-content: space-between; gap: 0.4rem; color: var(--dim); }
|
||
.ap-type { flex: 1 1 auto; margin-left: 0.4rem; background: var(--panel-2); color: var(--text);
|
||
border: 1px solid var(--border); border-radius: 5px; padding: 0.2rem 0.3rem; text-transform: capitalize; }
|
||
.ap-desc { color: var(--text); line-height: 1.35; }
|
||
.ap-meta { text-transform: capitalize; }
|
||
.ap-annotate { width: 100%; text-align: center; }
|
||
.ap-hint { line-height: 1.35; min-height: 1.1em; }
|
||
|
||
.annot-rect {
|
||
position: absolute; border: 1.5px solid var(--accent); background: rgba(89,212,153,0.14);
|
||
pointer-events: none; z-index: 6; border-radius: 2px;
|
||
}
|
||
|
||
/* 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>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div id="topbar">
|
||
<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>
|
||
<span class="ctl-sep"></span>
|
||
<button class="btn" id="btn-key" title="Add camera keyframe here (K)">+ Key <b id="key-count">0</b></button>
|
||
<button class="btn" id="btn-path" title="Play the keyframed camera path">▶ Path</button>
|
||
<button class="btn" id="btn-path-export" title="Export path as JSON">⤓</button>
|
||
<button class="btn" id="btn-path-import" title="Import path JSON">⤒</button>
|
||
<button class="btn" id="btn-path-clear" title="Clear all keyframes">✕</button>
|
||
<input type="file" id="path-file" accept="application/json,.json" hidden />
|
||
</div>
|
||
<div id="scene-hint">drag to orbit · 1–9 snap to camera · K add keyframe · Esc detach</div>
|
||
<div id="annot-panel"></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>
|
||
</body>
|
||
</html>
|