What now works: - M0 scaffold: pyproject (all spec deps), uv/py3.12 env, `python -m festival4d` CLI registering synthetic|ingest|sync|reconstruct|events|serve. Vite hello page. - Synthetic fixture (synthetic.py): 3 shifted-audio videos (offsets 0/+1370/-842 ms), camera-arc poses, stage point cloud -> points.ply, seeded events + anchors, ground_truth.json. `python -m festival4d synthetic` populates data/ + DB. - DB schema exactly per spec §2 (db.py) + CRUD helpers all lanes use. - M3 API (api.py) full against synthetic data: manifest/poses/pointcloud/anchors/ events/detect/annotations; Range-capable video serving (206 verified); CORS for any localhost origin. - Frozen geometry contract: geometry.colmap_to_threejs (M5 math) + unit test (3 known vectors, random round-trip, scipy oracle); mirrored frontend/src/lib/pose.js with identical POSE_TEST_VECTORS. Lane-B stubs: slerp_pose, ray_from_pixel, triangulate_rays, nearest_point_on_ray. - Classifier contract (events_ai.py): MomentClassification model + MomentClassifier protocol + Gemini/Claude/Local provider stubs. - Lane-owned modules stubbed with final signatures (ingest, audio_sync, frames, sfm, events_ai); cli/api catch NotImplementedError and degrade gracefully. - plan/CHANGE_REQUESTS.md created; plan/status/foundation.md updated. Acceptance: pytest 24 passed; serve endpoints verified via curl + browser (video seek, manifest fetch cross-origin, pose.js self-test, 0 console errors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
6.7 KiB
Python
210 lines
6.7 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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_poses": db.has_poses(),
|
|
}
|
|
|
|
|
|
@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/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.post("/api/annotations")
|
|
def create_annotation(payload: AnnotationIn) -> dict:
|
|
x0, y0, x1, y1 = payload.bbox
|
|
annotation = db.add_annotation(payload.video_id, payload.t_video_s, x0, y0, x1, y1)
|
|
|
|
# M8 resolution (triangulate / nearest-point) is integration work; geometry stubs raise
|
|
# until lane B lands. Store the annotation now; resolve to an anchor later.
|
|
anchor_id = None
|
|
point = None
|
|
note = "annotation stored; 3D resolution is M8/integration work"
|
|
return {
|
|
"annotation_id": annotation.id,
|
|
"anchor_id": anchor_id,
|
|
"point": point,
|
|
"note": note,
|
|
}
|