festifun/frontend/src/state.js
m3ultra f12b6e0847 Splat upgrade (ideas.md #1) + MODELBEAST crossover
- GET /api/splat serves data/work/splat.ply; manifest reports has_splat
- scene3d: lazy-load @mkkellogg/gaussian-splats-3d DropInViewer when a splat
  exists (rigs/anchors/paths overlay as before); point-cloud fallback intact
- scripts/splat_via_modelbeast.sh: train the splat on the MODELBEAST fleet
  (colmap_poses -> brush_train via the mb queue) and drop it in place
- docs/modelbeast-crossover.md: full crossover map (fleet compute, volumetric
  capture w/ CorridorKey + audio sync, per-moment splats)
Verified in-browser: 32MB room splat rendered via /api/splat with overlays live.
2026-07-16 22:32:01 +10:00

59 lines
2.6 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.
export const 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)
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;
}