festifun/backend/festival4d/api.py
m3ultra 36bacf67f0 Live capture: USB webcams, laptop cams, and phones -> data/raw
Records rather than streams: each device records a clip WITH AUDIO and uploads
it; the normal offline pipeline takes over. Fits the design almost for free —
MediaRecorder's webm/mp4 are already ingestible, and audio-based sync means
devices need no clock sync and needn't start together.

- capture.py: POST /api/capture/upload (streamed, sanitized name, size cap,
  partial cleanup), heartbeat/devices registry, GET /capture page.
- static/capture.html: standalone page (no build step) — device picker, live
  preview, record->upload, 1fps snapshot heartbeat, 'who's shooting what'
  monitor, and secure-context detection that tells you to use
  (getUserMedia needs HTTPS — the #1 gotcha for phones).
- OPT-IN via FESTIVAL4D_CAPTURE=1; manifest reports has_capture and the viewer
  only then shows a Capture link. Public deploy leaves it off (open upload
  endpoint would be unsafe) — noted in DEPLOY.md.
- Adds python-multipart dep. Suite 103 -> 119.

Also fixes test_manifest_shape, which was already RED on main: f12b6e0 added
has_splat to the manifest without updating the exact-set lock. Updated to the
true set (now also has_capture); the lock is what caught both drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:43:51 +10:00

314 lines
11 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/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}