- 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.
59 lines
2.6 KiB
JavaScript
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;
|
|
}
|