festifun/frontend/src/state.js
type-two 8f2a488c51 foundation3 (M18): complete Friend-Tracks foundation — fixture ground truth, contracts, stubs
Completes M18 on top of the WIP checkpoint (6a703c3). Three lanes (H/J/K) depend only on
what this freezes.

- synthetic.py: base testsrc2 saturation/brightness-muted so its magenta/cyan colour bars
  don't swamp lane H's colour detector; the two moving markers stay the only detectable blobs.
- Frontend (step 6): friendTracks.js stub (never-throws, fx.js pattern) wired into main.js;
  hidden #btn-friends + #friends-panel; state.tracks loaded when manifest.has_tracks.
- Tests (step 8): test_tracks_foundation.py (20) — DB helpers, /api/tracks routes, solve
  degradation, manifest.has_tracks, frozen OOK protocol + encoders, track_truth shape, the
  projection round-trip (rendered pixels back-project to 3D truth, worst err <1e-3 → lane H's
  median<0.3 reachable by construction), and a real-ffmpeg render test proving marker A is the
  dominant magenta blob after base-muting.
- CR-6: the two sanctioned M18 test-lock edits (test_api manifest key-set +has_tracks;
  test_capsule bundle paths +api/tracks). Additive; flagged for integration3 ratification.

Suite: 14 pre-existing files sum to 189 (floor held) + 20 new = 209 passed, exit 0.
Frontend `npm run build` clean. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).

Recovered by the coordinator session after the foundation3 agent's isolation worktree was
removed mid-run, leaving the work uncommitted; every file independently reviewed, full suite +
build re-run green, then committed. No work redone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:18:53 +10:00

67 lines
3.3 KiB
JavaScript

// Global store: manifest, playhead (t_global), playing, rate, selected camera, audio source,
// plus the loaded poses / anchors / events and per-video live playback status. Single source
// 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.
// API origin/prefix. Local dev talks to the uvicorn server cross-origin on :8000 (default).
// A hosted build sets VITE_API_BASE to a same-origin path prefix (e.g. "/festifun") so every
// request — fetch, <video> src, PLY loader — goes through the reverse proxy under that path.
// Empty string = same-origin at root. See deploy/DEPLOY.md.
// Phase-5 contract #5: a RUNTIME override wins over the build-time value — the memory capsule
// (M17) injects `window.__F4D_API_BASE__ = ""` into its baked index.html so the same dist
// bundle turns zero-backend. `??` (not `||`): empty string is a valid value (same-origin).
export const API_BASE =
window.__F4D_API_BASE__ ?? import.meta.env.VITE_API_BASE ?? "http://localhost:8000";
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,
hasSplat: false, // optional 3DGS splat at /api/splat (preferred over the point cloud)
capsule: false, // manifest.capsule (M17): static baked bundle => hide ALL write UI
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}]
hasTracks: false, // manifest.has_tracks (M18): any solved friend track => load + render tracks
tracks: [], // [{id, marker_key, label, color, points:[{t_global_s,x,y,z,quality,views}]}] (M18)
// transport / playback
tGlobal: 0,
playing: false,
rate: 1,
audioSourceId: null, // video id whose audio you hear (WebAudio soundtrack; fallback: unmuted <video>)
spatialAudio: false, // 🎧 every camera is a positional emitter at its 3D pose; listener = viewer cam
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;
}