festifun/frontend/index.html
m3ultra c24af8029e Audio is the master clock + spatial audio free-roam (fresh-eyes opts 1 & feature 1)
Master clock: the selected soundtrack (new GET /api/audio/{id} —
listening-quality stereo AAC, lazily ffmpeg-extracted and cached in
data/work/audio_hq/) plays through WebAudio, and t_global derives from
AudioContext.currentTime. Every <video> is muted picture-only, so hard
seeks and playbackRate corrections are inaudible (the unmuted video
used to warble on every nudge). Graceful fallback to the old
performance.now() clock + <video> audio when a track can't load, with
an in-place upgrade if the decode lands mid-play. Drift is compensated
in the track's playbackRate; _resync() now recomputes from the live
clock since the soundtrack keeps playing while rAF is throttled.

Spatial audio (🎧 3D audio, shown when poses exist): every enabled
camera's track plays through an HRTF PannerNode at its live
reconstructed pose, gains normalized 1/sqrt(N); the listener follows
the viewer camera each frame (free roam, follow-cam, and M9 paths).
Tracks that finish decoding mid-play join the mix in place; toggling,
seeking, rate and enable changes rebuild the graph at the current
t_global with no timeline jump.

The window.__f4d debug hook is now also available in production builds
via localStorage f4dDebug=1 (headless panes throttle rAF; the pump is
the only way to drive the loop there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:48 +10:00

273 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
.ap-anchors { border-top: 1px solid var(--border); margin-top: 0.15rem; padding-top: 0.4rem; }
.ap-anchors-hd { font-size: 0.72rem; margin-bottom: 0.25rem; }
.ap-anchors-list { max-height: 132px; overflow-y: auto; display: flex; flex-direction: column; gap: 0.15rem; }
.ap-anchors-empty { font-size: 0.74rem; }
.ap-anchor-row { display: flex; align-items: center; gap: 0.35rem; font-size: 0.74rem; }
.ap-anchor-dot { width: 8px; height: 8px; border-radius: 3px; flex: 0 0 auto; }
.ap-anchor-label { flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ap-anchor-del {
background: none; border: none; color: var(--dim); cursor: pointer; font-size: 0.75rem;
padding: 0 0.15rem; flex: 0 0 auto;
}
.ap-anchor-del:hover { color: var(--bad); }
.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>
<span class="spacer"></span>
<!-- shown only when the backend has capture enabled (FESTIVAL4D_CAPTURE=1) -->
<a id="capture-link" class="btn" style="display:none; text-decoration:none"
title="Record from webcams / phones into this project" target="_blank">📹 Capture</a>
</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>
<span class="ctl-sep"></span>
<button class="btn" id="btn-spatial" style="display:none"
title="Spatial audio: hear every camera from its position in the scene">🎧 3D audio</button>
<input type="file" id="path-file" accept="application/json,.json" hidden />
</div>
<div id="scene-hint">drag to orbit · 19 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>