- DB: paths table (contract #3) + helpers; patch_anchor (label/color) - API: GET /api/beats, POST /api/director (valid-empty degradation), /api/paths CRUD (validated POST -> 422), PATCH /api/anchors/{id}; manifest gains capsule:false (test_api.py key-set lock updated consciously) - CLI: features | direct | capsule subcommands -> lane stubs (exit-2 degradation) - Backend stubs with frozen contracts in docstrings: audio_features, director, capsule - Frontend: six lane stub modules imported from main.js; all phase-5 buttons pre-wired hidden in index.html; write-ui/body.capsule read-only mode; __F4D_API_BASE__ runtime override in state.js - Hooks (contract #6): transport.captureAudioStream/releaseAudioStream, scene3d.focusOn, scene3d.setHelpersVisible (+registerHelper; camPath gizmos) - Suite 121 -> 127 passed; npm build clean; live-browser verified (status file) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
468 lines
17 KiB
Python
468 lines
17 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?}
|
|
|
|
Phase 5 (foundation2; shapes frozen in plan/20-phase5.md contracts):
|
|
|
|
GET /api/beats -> beats.json body (contract #1); 404 when not yet analyzed
|
|
POST /api/director {top_n?, lead_s?} -> camPath JSON (contract #2); while lane E
|
|
is stubbed, degrades to a VALID empty path + "note"
|
|
GET /api/paths -> [{id, name, created_at}]
|
|
GET /api/paths/{id} -> {id, name, created_at, json: <parsed camPath JSON>}
|
|
POST /api/paths {name, json: <camPath JSON as text>} (validated -> 422)
|
|
DELETE /api/paths/{id}
|
|
PATCH /api/anchors/{id} {label?, color?} -> updated anchor dict
|
|
|
|
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
|
|
|
|
|
|
class AnchorPatch(BaseModel):
|
|
label: str | None = None
|
|
color: str | None = None
|
|
|
|
|
|
class DirectorIn(BaseModel):
|
|
top_n: int = 8
|
|
lead_s: float = 2.0
|
|
|
|
|
|
class PathIn(BaseModel):
|
|
# Wire key is "json" (contract #3); aliased because `json` shadows a BaseModel attr.
|
|
name: str
|
|
path_json: str = Field(alias="json")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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(),
|
|
# Always False from the live server; the capsule (M17) bakes a manifest with true,
|
|
# which makes the frontend hide all write UI (phase-5 contract #5).
|
|
"capsule": False,
|
|
}
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Phase 5 (foundation2). Shapes are frozen in plan/20-phase5.md "Contracts".
|
|
# ---------------------------------------------------------------------------
|
|
@app.get("/api/beats")
|
|
def get_beats() -> dict:
|
|
"""The beats.json analysis (contract #1). 404 until `python -m festival4d features` runs."""
|
|
import json
|
|
|
|
if not config.BEATS_JSON.exists():
|
|
raise HTTPException(status_code=404, detail="no beat analysis (run `python -m festival4d features`)")
|
|
return json.loads(config.BEATS_JSON.read_text())
|
|
|
|
|
|
@app.post("/api/director")
|
|
def run_director(payload: DirectorIn) -> dict:
|
|
"""Auto-director (M11) -> camPath JSON. Degrades to a valid EMPTY path while stubbed,
|
|
so the response always round-trips through camPath.fromJSON (contract #2)."""
|
|
from festival4d import director
|
|
|
|
try:
|
|
return director.generate_path(top_n=payload.top_n, lead_s=payload.lead_s)
|
|
except NotImplementedError as exc:
|
|
log.info("auto-director not implemented yet: %s", exc)
|
|
return {"version": 1, "keyframes": [], "note": "auto-director not implemented yet (lane E / M11)"}
|
|
|
|
|
|
def _validate_campath(text: str) -> None:
|
|
"""422 unless ``text`` is the frozen camPath JSON (contract #2). Lane G owns hardening."""
|
|
import json
|
|
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError:
|
|
raise HTTPException(status_code=422, detail="path json is not valid JSON")
|
|
ok = (
|
|
isinstance(data, dict)
|
|
and data.get("version") == 1
|
|
and isinstance(data.get("keyframes"), list)
|
|
and all(
|
|
isinstance(k, dict)
|
|
and isinstance(k.get("t_global"), (int, float))
|
|
and isinstance(k.get("pos"), list) and len(k["pos"]) == 3
|
|
and isinstance(k.get("quat"), list) and len(k["quat"]) == 4
|
|
and isinstance(k.get("fov"), (int, float))
|
|
for k in data.get("keyframes", [])
|
|
)
|
|
)
|
|
if not ok:
|
|
raise HTTPException(status_code=422, detail="not a camPath JSON (see plan/20-phase5.md contract #2)")
|
|
|
|
|
|
def _path_dict(p, with_json: bool = False) -> dict:
|
|
import json
|
|
|
|
d = {"id": p.id, "name": p.name, "created_at": p.created_at}
|
|
if with_json:
|
|
d["json"] = json.loads(p.path_json)
|
|
return d
|
|
|
|
|
|
@app.get("/api/paths")
|
|
def get_paths() -> list[dict]:
|
|
return [_path_dict(p) for p in db.get_paths()]
|
|
|
|
|
|
@app.get("/api/paths/{path_id}")
|
|
def get_path(path_id: int) -> dict:
|
|
p = db.get_path(path_id)
|
|
if p is None:
|
|
raise HTTPException(status_code=404, detail=f"no path with id={path_id}")
|
|
return _path_dict(p, with_json=True)
|
|
|
|
|
|
@app.post("/api/paths")
|
|
def create_path(payload: PathIn) -> dict:
|
|
_validate_campath(payload.path_json)
|
|
return _path_dict(db.add_path(payload.name, payload.path_json), with_json=True)
|
|
|
|
|
|
@app.delete("/api/paths/{path_id}")
|
|
def delete_path(path_id: int) -> dict:
|
|
if not db.delete_path(path_id):
|
|
raise HTTPException(status_code=404, detail=f"no path with id={path_id}")
|
|
return {"deleted": path_id}
|
|
|
|
|
|
@app.patch("/api/anchors/{anchor_id}")
|
|
def patch_anchor(anchor_id: int, payload: AnchorPatch) -> dict:
|
|
"""Rename / recolor an anchor in place (M13, contract #4)."""
|
|
try:
|
|
anchor = db.patch_anchor(anchor_id, label=payload.label, color=payload.color)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
|
|
return _anchor_dict(anchor)
|
|
|
|
|
|
@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}
|