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>
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""FastAPI app (spec M3). FROZEN after foundation — lanes never edit routes or shapes.
|
|
|
|
Serves the synthetic project fully so lane C can treat this API as finished:
|
|
|
|
GET /api/manifest -> {videos:[{id,filename,url,duration_s,fps,width,height,
|
|
offset_ms,drift_ppm}], t_global_max, has_poses}
|
|
GET /api/videos/{id}/poses -> [{frame_idx,t_video_s,q:[w,x,y,z],t:[x,y,z],
|
|
intrinsics:{fx,fy,cx,cy},registered}]
|
|
GET /api/pointcloud -> points.ply (binary; Range-capable)
|
|
GET /api/anchors -> [...] POST /api/anchors
|
|
GET /api/events -> [...]
|
|
POST /api/events/detect {t_global_s?, window_s?} -> runs M7 detection
|
|
POST /api/annotations {video_id, t_video_s, bbox:[x0,y0,x1,y1]} -> {anchor_id?, point?}
|
|
|
|
Source videos in ``data/raw`` are mounted at ``/media`` via Starlette ``StaticFiles``,
|
|
which supports HTTP Range (required for ``<video>`` seeking; verify ``curl -H "Range:
|
|
bytes=0-100"`` -> 206). Stubbed lane entrypoints (events detection, M8 annotation
|
|
resolution) raise ``NotImplementedError``; we catch it here and degrade to a 200 with a
|
|
note so the frozen routes never 500 while a lane is still stubbed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel, Field
|
|
|
|
from festival4d import config, db
|
|
|
|
log = logging.getLogger("festival4d.api")
|
|
|
|
app = FastAPI(title="Festival 4D", version="0.1.0")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=config.FRONTEND_ORIGINS,
|
|
allow_origin_regex=config.FRONTEND_ORIGIN_REGEX,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Bind the DB and ensure the media directory exists so the static mount is valid even
|
|
# before the synthetic fixture has been generated.
|
|
db.init_engine()
|
|
db.init_db()
|
|
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# HTTP Range-capable static serving of source videos (spec M3, pitfall #3).
|
|
app.mount("/media", StaticFiles(directory=config.RAW_DIR), name="media")
|
|
|
|
# Live capture (webcams / phones -> data/raw). OPT-IN: these routes accept file uploads, so they
|
|
# stay unmounted unless FESTIVAL4D_CAPTURE=1 — a public deployment must never expose them.
|
|
from festival4d import capture # noqa: E402 (import after app/config are ready)
|
|
|
|
if capture.capture_enabled():
|
|
app.include_router(capture.router)
|
|
log.info("capture routes ENABLED (/capture, /api/capture/*) — FESTIVAL4D_CAPTURE is set")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request models
|
|
# ---------------------------------------------------------------------------
|
|
class AnchorIn(BaseModel):
|
|
label: str
|
|
x: float
|
|
y: float
|
|
z: float
|
|
color: str | None = None
|
|
|
|
|
|
class DetectIn(BaseModel):
|
|
t_global_s: float | None = None
|
|
window_s: float | None = None
|
|
|
|
|
|
class AnnotationIn(BaseModel):
|
|
video_id: int
|
|
t_video_s: float
|
|
bbox: list[float] = Field(min_length=4, max_length=4) # [x0, y0, x1, y1] normalized
|
|
event_id: int | None = None # CR-2: link to the event this annotation locates (M8)
|
|
|
|
|
|
class EventPatch(BaseModel):
|
|
event_type: str | None = None
|
|
source: str | None = None
|
|
description: str | None = None
|
|
confidence: float | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialization helpers
|
|
# ---------------------------------------------------------------------------
|
|
def _video_dict(v) -> dict:
|
|
return {
|
|
"id": v.id,
|
|
"filename": v.filename,
|
|
"url": f"/media/{v.filename}",
|
|
"duration_s": v.duration_s,
|
|
"fps": v.fps,
|
|
"width": v.width,
|
|
"height": v.height,
|
|
"offset_ms": v.offset_ms,
|
|
"drift_ppm": v.drift_ppm,
|
|
}
|
|
|
|
|
|
def _pose_dict(p) -> dict:
|
|
return {
|
|
"frame_idx": p.frame_idx,
|
|
"t_video_s": p.t_video_s,
|
|
"q": [p.qw, p.qx, p.qy, p.qz],
|
|
"t": [p.tx, p.ty, p.tz],
|
|
"intrinsics": {"fx": p.fx, "fy": p.fy, "cx": p.cx, "cy": p.cy},
|
|
"registered": p.registered,
|
|
}
|
|
|
|
|
|
def _anchor_dict(a) -> dict:
|
|
return {"id": a.id, "label": a.label, "x": a.x, "y": a.y, "z": a.z, "color": a.color}
|
|
|
|
|
|
def _event_dict(e) -> dict:
|
|
return {
|
|
"id": e.id,
|
|
"t_global_s": e.t_global_s,
|
|
"duration_s": e.duration_s,
|
|
"event_type": e.event_type,
|
|
"confidence": e.confidence,
|
|
"description": e.description,
|
|
"source": e.source,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
@app.get("/api/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/manifest")
|
|
def manifest() -> dict:
|
|
videos = db.get_videos()
|
|
t_global_max = 0.0
|
|
for v in videos:
|
|
offset_s = (v.offset_ms or 0.0) / 1000.0
|
|
t_global_max = max(t_global_max, offset_s + v.duration_s)
|
|
return {
|
|
"videos": [_video_dict(v) for v in videos],
|
|
"t_global_max": t_global_max,
|
|
"has_capture": capture.capture_enabled(),
|
|
"has_poses": db.has_poses(),
|
|
"has_splat": config.SPLAT_PLY.exists(),
|
|
}
|
|
|
|
|
|
@app.get("/api/videos/{video_id}/poses")
|
|
def video_poses(video_id: int) -> list[dict]:
|
|
if db.get_video(video_id) is None:
|
|
raise HTTPException(status_code=404, detail=f"no video with id={video_id}")
|
|
return [_pose_dict(p) for p in db.get_poses(video_id)]
|
|
|
|
|
|
@app.get("/api/pointcloud")
|
|
def pointcloud() -> FileResponse:
|
|
if not config.POINTS_PLY.exists():
|
|
raise HTTPException(status_code=404, detail="no point cloud (run `synthetic` or `reconstruct`)")
|
|
return FileResponse(
|
|
config.POINTS_PLY,
|
|
media_type="application/octet-stream",
|
|
filename="points.ply",
|
|
)
|
|
|
|
|
|
@app.get("/api/splat")
|
|
def splat() -> FileResponse:
|
|
"""Optional 3DGS splat of the scene (see docs/modelbeast-crossover.md).
|
|
|
|
Trained externally from the same COLMAP reconstruction (e.g. MODELBEAST's
|
|
brush_train) and dropped at ``data/work/splat.ply``. The viewer prefers it
|
|
over the sparse point cloud when present.
|
|
"""
|
|
if not config.SPLAT_PLY.exists():
|
|
raise HTTPException(status_code=404, detail="no splat (train one — see docs/modelbeast-crossover.md)")
|
|
return FileResponse(
|
|
config.SPLAT_PLY,
|
|
media_type="application/octet-stream",
|
|
filename="splat.ply",
|
|
)
|
|
|
|
|
|
@app.get("/api/audio/{video_id}")
|
|
def video_audio(video_id: int) -> FileResponse:
|
|
"""Listening-quality audio track of a video (stereo AAC), for the WebAudio master clock.
|
|
|
|
Extracted lazily from the source video via ffmpeg on first request and cached in
|
|
``data/work/audio_hq/`` (re-extracted if the source file is newer). The frontend
|
|
decodes this whole file with ``decodeAudioData`` and derives ``t_global`` from
|
|
``AudioContext.currentTime`` — see ``frontend/src/transport.js``.
|
|
"""
|
|
from festival4d import ingest
|
|
import subprocess
|
|
|
|
v = db.get_video(video_id)
|
|
if v is None:
|
|
raise HTTPException(status_code=404, detail=f"no video with id={video_id}")
|
|
src = config.RAW_DIR / v.filename
|
|
if not src.exists():
|
|
raise HTTPException(status_code=404, detail=f"source file missing: {v.filename}")
|
|
out = config.AUDIO_HQ_DIR / f"{video_id}.m4a"
|
|
if not out.exists() or out.stat().st_mtime < src.stat().st_mtime:
|
|
try:
|
|
ingest.extract_audio_hq(src, out)
|
|
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
|
# No audio stream / no ffmpeg — the frontend falls back to <video> audio.
|
|
raise HTTPException(status_code=404, detail=f"no extractable audio: {exc}") from exc
|
|
return FileResponse(out, media_type="audio/mp4", filename=out.name)
|
|
|
|
|
|
@app.get("/api/anchors")
|
|
def get_anchors() -> list[dict]:
|
|
return [_anchor_dict(a) for a in db.get_anchors()]
|
|
|
|
|
|
@app.post("/api/anchors")
|
|
def create_anchor(payload: AnchorIn) -> dict:
|
|
anchor = db.add_anchor(payload.label, payload.x, payload.y, payload.z, payload.color)
|
|
return _anchor_dict(anchor)
|
|
|
|
|
|
@app.get("/api/events")
|
|
def get_events() -> list[dict]:
|
|
return [_event_dict(e) for e in db.get_events()]
|
|
|
|
|
|
@app.post("/api/events/detect")
|
|
def detect_events(payload: DetectIn) -> dict:
|
|
from festival4d import events_ai
|
|
|
|
try:
|
|
result = events_ai.run_events(t_global_s=payload.t_global_s, window_s=payload.window_s)
|
|
except NotImplementedError as exc:
|
|
log.info("events detection not implemented yet: %s", exc)
|
|
return {
|
|
"result": None,
|
|
"note": "moment detection not implemented yet (lane D / M7)",
|
|
"events": [_event_dict(e) for e in db.get_events()],
|
|
}
|
|
return {"result": result, "events": [_event_dict(e) for e in db.get_events()]}
|
|
|
|
|
|
@app.patch("/api/events/{event_id}")
|
|
def patch_event(event_id: int, payload: EventPatch) -> dict:
|
|
"""Correct an event in place (M8). A user correction defaults ``source`` to ``'user'``."""
|
|
if db.get_event(event_id) is None:
|
|
raise HTTPException(status_code=404, detail=f"no event with id={event_id}")
|
|
source = payload.source
|
|
if source is None and (payload.event_type is not None or payload.description is not None):
|
|
source = "user" # any user-driven correction is attributed to the user
|
|
event = db.update_event(
|
|
event_id,
|
|
event_type=payload.event_type,
|
|
source=source,
|
|
description=payload.description,
|
|
confidence=payload.confidence,
|
|
)
|
|
return _event_dict(event)
|
|
|
|
|
|
@app.post("/api/annotations")
|
|
def create_annotation(payload: AnnotationIn) -> dict:
|
|
from festival4d import resolve
|
|
|
|
x0, y0, x1, y1 = payload.bbox
|
|
if db.get_video(payload.video_id) is None:
|
|
raise HTTPException(status_code=404, detail=f"no video with id={payload.video_id}")
|
|
annotation = db.add_annotation(
|
|
payload.video_id, payload.t_video_s, x0, y0, x1, y1, event_id=payload.event_id
|
|
)
|
|
|
|
# M8: cast a ray through the bbox center; triangulate against another view of the same
|
|
# event, else fall back to the nearest point-cloud point / centroid depth.
|
|
result = resolve.resolve_annotation(
|
|
payload.video_id, payload.t_video_s, payload.bbox, event_id=payload.event_id
|
|
)
|
|
|
|
anchor = None
|
|
superseded = False
|
|
if result.point is not None:
|
|
px, py, pz = result.point
|
|
label = "annotation"
|
|
if payload.event_id is not None:
|
|
ev = db.get_event(payload.event_id)
|
|
if ev is not None:
|
|
label = ev.description or ev.event_type or label
|
|
|
|
# One anchor per event: the first annotation creates it (a single-view fallback); a later
|
|
# view that TRIANGULATES supersedes the anchor in place rather than dropping a sibling. A
|
|
# later fallback links to the existing anchor without moving it (don't regress a good
|
|
# triangulation). Annotations with no event stay independent (each makes its own anchor).
|
|
existing_id = _existing_event_anchor_id(payload.event_id) if payload.event_id else None
|
|
if existing_id is None:
|
|
anchor = db.add_anchor(label, px, py, pz, None)
|
|
elif result.method == "triangulated":
|
|
anchor = db.update_anchor(existing_id, px, py, pz, label=label)
|
|
superseded = True
|
|
else:
|
|
anchor = db.get_anchor(existing_id)
|
|
if anchor is not None:
|
|
db.set_annotation_anchor(annotation.id, anchor.id)
|
|
|
|
return {
|
|
"annotation_id": annotation.id,
|
|
"anchor_id": anchor.id if anchor else None,
|
|
"point": result.point,
|
|
"method": result.method,
|
|
"gap": result.gap,
|
|
"superseded": superseded,
|
|
"anchor": _anchor_dict(anchor) if anchor else None,
|
|
}
|
|
|
|
|
|
def _existing_event_anchor_id(event_id: int) -> int | None:
|
|
"""The anchor resolved from a prior annotation of this event (most recent), or None."""
|
|
for ann in reversed(db.get_annotations(event_id=event_id)):
|
|
if ann.resolved_anchor_id is not None:
|
|
return ann.resolved_anchor_id
|
|
return None
|
|
|
|
|
|
@app.delete("/api/anchors/{anchor_id}")
|
|
def delete_anchor(anchor_id: int) -> dict:
|
|
"""Remove an anchor (M8 / Phase 4). Unlinks any annotations that resolved to it."""
|
|
if not db.delete_anchor(anchor_id):
|
|
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
|
|
return {"deleted": anchor_id}
|