- 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>
556 lines
19 KiB
Python
556 lines
19 KiB
Python
"""SQLAlchemy engine/session and schema (spec §2) plus small CRUD helpers.
|
|
|
|
FROZEN CONTRACT after foundation: the schema (table/column names and types) and the
|
|
helper signatures are what every lane depends on. Lanes write through these helpers only.
|
|
|
|
Schema (spec §2)::
|
|
|
|
videos(id, filename, duration_s, fps, width, height, offset_ms, drift_ppm,
|
|
sync_confidence, created_at)
|
|
camera_poses(id, video_id FK, frame_idx, t_video_s, qw,qx,qy,qz, tx,ty,tz,
|
|
fx,fy,cx,cy, registered) # COLMAP world->camera; registered=False => interpolated
|
|
anchors(id, label, x,y,z, color)
|
|
events(id, t_global_s, duration_s, event_type, confidence, description, source)
|
|
annotations(id, video_id FK, t_video_s, x0,y0,x1,y1, resolved_anchor_id FK NULL)
|
|
paths(id, name, created_at, path_json) # phase 5 (M16): saved camPath JSON, as text
|
|
|
|
Engine management: :func:`init_engine` (re)binds the module to a SQLite file. It defaults
|
|
to ``config.DB_PATH`` but tests point it at a temp file. Helpers open and commit their own
|
|
short-lived sessions and return detached ORM instances (``expire_on_commit=False``), so the
|
|
returned objects are safe to read after the session closes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Iterable, Iterator, Sequence
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Float,
|
|
ForeignKey,
|
|
Integer,
|
|
String,
|
|
create_engine,
|
|
delete,
|
|
select,
|
|
update,
|
|
)
|
|
from sqlalchemy.engine import Engine
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
|
|
|
from festival4d import config
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ORM models
|
|
# ---------------------------------------------------------------------------
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
|
|
|
|
class Video(Base):
|
|
__tablename__ = "videos"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
filename: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
|
duration_s: Mapped[float] = mapped_column(Float, nullable=False)
|
|
fps: Mapped[float] = mapped_column(Float, nullable=False)
|
|
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
offset_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
drift_ppm: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
sync_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
|
|
|
|
|
class CameraPose(Base):
|
|
__tablename__ = "camera_poses"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"), nullable=False, index=True)
|
|
frame_idx: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
t_video_s: Mapped[float] = mapped_column(Float, nullable=False)
|
|
# COLMAP world->camera quaternion [w,x,y,z] + translation
|
|
qw: Mapped[float] = mapped_column(Float, nullable=False)
|
|
qx: Mapped[float] = mapped_column(Float, nullable=False)
|
|
qy: Mapped[float] = mapped_column(Float, nullable=False)
|
|
qz: Mapped[float] = mapped_column(Float, nullable=False)
|
|
tx: Mapped[float] = mapped_column(Float, nullable=False)
|
|
ty: Mapped[float] = mapped_column(Float, nullable=False)
|
|
tz: Mapped[float] = mapped_column(Float, nullable=False)
|
|
# pinhole intrinsics after undistortion
|
|
fx: Mapped[float] = mapped_column(Float, nullable=False)
|
|
fy: Mapped[float] = mapped_column(Float, nullable=False)
|
|
cx: Mapped[float] = mapped_column(Float, nullable=False)
|
|
cy: Mapped[float] = mapped_column(Float, nullable=False)
|
|
registered: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
|
|
|
|
class Anchor(Base):
|
|
__tablename__ = "anchors"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
label: Mapped[str] = mapped_column(String, nullable=False)
|
|
x: Mapped[float] = mapped_column(Float, nullable=False)
|
|
y: Mapped[float] = mapped_column(Float, nullable=False)
|
|
z: Mapped[float] = mapped_column(Float, nullable=False)
|
|
color: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
|
|
|
|
class Event(Base):
|
|
__tablename__ = "events"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
t_global_s: Mapped[float] = mapped_column(Float, nullable=False)
|
|
duration_s: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
event_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
description: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
source: Mapped[str] = mapped_column(String, nullable=False) # 'audio_auto' | 'ai' | 'user'
|
|
|
|
|
|
class Annotation(Base):
|
|
__tablename__ = "annotations"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"), nullable=False, index=True)
|
|
t_video_s: Mapped[float] = mapped_column(Float, nullable=False)
|
|
x0: Mapped[float] = mapped_column(Float, nullable=False)
|
|
y0: Mapped[float] = mapped_column(Float, nullable=False)
|
|
x1: Mapped[float] = mapped_column(Float, nullable=False)
|
|
y1: Mapped[float] = mapped_column(Float, nullable=False)
|
|
# Nullable link to the event this annotation locates (CR-2, M8). Two annotations sharing an
|
|
# event_id from different videos triangulate to a 3D anchor. Nullable => additive/back-compat.
|
|
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id"), nullable=True)
|
|
resolved_anchor_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("anchors.id"), nullable=True
|
|
)
|
|
|
|
|
|
class SavedPath(Base):
|
|
"""A saved keyframed camera path (phase 5 contract #3). ``path_json`` is the frozen
|
|
camPath JSON (``{"version": 1, "keyframes": [...]}``) stored verbatim as text —
|
|
the DB never interprets it beyond POST-time validation in the API layer."""
|
|
|
|
__tablename__ = "paths"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
|
path_json: Mapped[str] = mapped_column(String, nullable=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Engine / session management
|
|
# ---------------------------------------------------------------------------
|
|
_engine: Engine | None = None
|
|
_SessionLocal: sessionmaker[Session] | None = None
|
|
_bound_path: Path | None = None
|
|
|
|
|
|
def init_engine(db_path: str | Path | None = None) -> Engine:
|
|
"""(Re)bind the module to a SQLite database file and return the engine.
|
|
|
|
Called with no argument uses ``config.DB_PATH``. Idempotent for the same path.
|
|
"""
|
|
global _engine, _SessionLocal, _bound_path
|
|
path = Path(db_path) if db_path is not None else config.DB_PATH
|
|
if _engine is not None and _bound_path == path:
|
|
return _engine
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if _engine is not None:
|
|
_engine.dispose()
|
|
_engine = create_engine(
|
|
f"sqlite:///{path}",
|
|
connect_args={"check_same_thread": False},
|
|
future=True,
|
|
)
|
|
_SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False, future=True)
|
|
_bound_path = path
|
|
return _engine
|
|
|
|
|
|
def engine() -> Engine:
|
|
if _engine is None:
|
|
init_engine()
|
|
assert _engine is not None
|
|
return _engine
|
|
|
|
|
|
@contextmanager
|
|
def session_scope() -> Iterator[Session]:
|
|
"""Transactional session context; commits on success, rolls back on error."""
|
|
if _SessionLocal is None:
|
|
init_engine()
|
|
assert _SessionLocal is not None
|
|
session = _SessionLocal()
|
|
try:
|
|
yield session
|
|
session.commit()
|
|
except Exception:
|
|
session.rollback()
|
|
raise
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def init_db() -> None:
|
|
"""Create all tables if they do not exist."""
|
|
Base.metadata.create_all(engine())
|
|
|
|
|
|
def reset_db() -> None:
|
|
"""Drop and recreate all tables (used by the synthetic fixture generator)."""
|
|
Base.metadata.drop_all(engine())
|
|
Base.metadata.create_all(engine())
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Videos
|
|
# ---------------------------------------------------------------------------
|
|
def add_video(
|
|
filename: str,
|
|
duration_s: float,
|
|
fps: float,
|
|
width: int,
|
|
height: int,
|
|
offset_ms: float | None = None,
|
|
drift_ppm: float | None = None,
|
|
sync_confidence: float | None = None,
|
|
) -> Video:
|
|
"""Insert a video row and return it (used by ingest + synthetic)."""
|
|
with session_scope() as s:
|
|
video = Video(
|
|
filename=filename,
|
|
duration_s=duration_s,
|
|
fps=fps,
|
|
width=width,
|
|
height=height,
|
|
offset_ms=offset_ms,
|
|
drift_ppm=drift_ppm,
|
|
sync_confidence=sync_confidence,
|
|
created_at=_now_iso(),
|
|
)
|
|
s.add(video)
|
|
s.flush()
|
|
s.refresh(video)
|
|
return video
|
|
|
|
|
|
def get_videos() -> list[Video]:
|
|
with session_scope() as s:
|
|
return list(s.scalars(select(Video).order_by(Video.id)))
|
|
|
|
|
|
def get_video(video_id: int) -> Video | None:
|
|
with session_scope() as s:
|
|
return s.get(Video, video_id)
|
|
|
|
|
|
def update_video_sync(
|
|
video_id: int,
|
|
offset_ms: float | None,
|
|
drift_ppm: float | None,
|
|
sync_confidence: float | None,
|
|
) -> None:
|
|
"""Persist the audio-sync solution for a video (lane A / M1)."""
|
|
with session_scope() as s:
|
|
video = s.get(Video, video_id)
|
|
if video is None:
|
|
raise KeyError(f"no video with id={video_id}")
|
|
video.offset_ms = offset_ms
|
|
video.drift_ppm = drift_ppm
|
|
video.sync_confidence = sync_confidence
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Camera poses
|
|
# ---------------------------------------------------------------------------
|
|
def set_poses(video_id: int, poses: Iterable[dict]) -> int:
|
|
"""Replace all poses for a video with ``poses`` (lane B / M2, and synthetic).
|
|
|
|
Each dict must have keys: frame_idx, t_video_s, qw, qx, qy, qz, tx, ty, tz,
|
|
fx, fy, cx, cy, and optionally ``registered`` (default True). Returns the count.
|
|
|
|
This is atomic: existing poses are only deleted inside the same transaction that
|
|
inserts the new ones, so a caller that raises before returning never corrupts the DB.
|
|
"""
|
|
rows = list(poses)
|
|
with session_scope() as s:
|
|
s.execute(delete(CameraPose).where(CameraPose.video_id == video_id))
|
|
for p in rows:
|
|
s.add(
|
|
CameraPose(
|
|
video_id=video_id,
|
|
frame_idx=int(p["frame_idx"]),
|
|
t_video_s=float(p["t_video_s"]),
|
|
qw=float(p["qw"]), qx=float(p["qx"]), qy=float(p["qy"]), qz=float(p["qz"]),
|
|
tx=float(p["tx"]), ty=float(p["ty"]), tz=float(p["tz"]),
|
|
fx=float(p["fx"]), fy=float(p["fy"]), cx=float(p["cx"]), cy=float(p["cy"]),
|
|
registered=bool(p.get("registered", True)),
|
|
)
|
|
)
|
|
return len(rows)
|
|
|
|
|
|
def get_poses(video_id: int) -> list[CameraPose]:
|
|
with session_scope() as s:
|
|
return list(
|
|
s.scalars(
|
|
select(CameraPose)
|
|
.where(CameraPose.video_id == video_id)
|
|
.order_by(CameraPose.t_video_s)
|
|
)
|
|
)
|
|
|
|
|
|
def has_poses() -> bool:
|
|
with session_scope() as s:
|
|
return s.scalar(select(CameraPose.id).limit(1)) is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Anchors
|
|
# ---------------------------------------------------------------------------
|
|
def add_anchor(label: str, x: float, y: float, z: float, color: str | None = None) -> Anchor:
|
|
with session_scope() as s:
|
|
anchor = Anchor(label=label, x=x, y=y, z=z, color=color)
|
|
s.add(anchor)
|
|
s.flush()
|
|
s.refresh(anchor)
|
|
return anchor
|
|
|
|
|
|
def get_anchors() -> list[Anchor]:
|
|
with session_scope() as s:
|
|
return list(s.scalars(select(Anchor).order_by(Anchor.id)))
|
|
|
|
|
|
def get_anchor(anchor_id: int) -> Anchor | None:
|
|
with session_scope() as s:
|
|
return s.get(Anchor, anchor_id)
|
|
|
|
|
|
def update_anchor(
|
|
anchor_id: int,
|
|
x: float,
|
|
y: float,
|
|
z: float,
|
|
label: str | None = None,
|
|
color: str | None = None,
|
|
) -> Anchor:
|
|
"""Move an anchor in place (M8 supersede). Position always updates; label/color only if given.
|
|
|
|
Raises ``KeyError`` if the id is unknown.
|
|
"""
|
|
with session_scope() as s:
|
|
anchor = s.get(Anchor, anchor_id)
|
|
if anchor is None:
|
|
raise KeyError(f"no anchor with id={anchor_id}")
|
|
anchor.x, anchor.y, anchor.z = x, y, z
|
|
if label is not None:
|
|
anchor.label = label
|
|
if color is not None:
|
|
anchor.color = color
|
|
s.flush()
|
|
s.refresh(anchor)
|
|
return anchor
|
|
|
|
|
|
def patch_anchor(anchor_id: int, label: str | None = None, color: str | None = None) -> Anchor:
|
|
"""Rename / recolor an anchor without moving it (phase 5 contract #4, M13).
|
|
|
|
Only provided fields change. Raises ``KeyError`` if the id is unknown.
|
|
"""
|
|
with session_scope() as s:
|
|
anchor = s.get(Anchor, anchor_id)
|
|
if anchor is None:
|
|
raise KeyError(f"no anchor with id={anchor_id}")
|
|
if label is not None:
|
|
anchor.label = label
|
|
if color is not None:
|
|
anchor.color = color
|
|
s.flush()
|
|
s.refresh(anchor)
|
|
return anchor
|
|
|
|
|
|
def delete_anchor(anchor_id: int) -> bool:
|
|
"""Delete an anchor, first unlinking any annotations that resolved to it.
|
|
|
|
Returns ``True`` if a row was deleted, ``False`` if the id was unknown. SQLite doesn't
|
|
enforce the FK by default, so we NULL ``annotations.resolved_anchor_id`` explicitly to keep
|
|
the link consistent.
|
|
"""
|
|
with session_scope() as s:
|
|
anchor = s.get(Anchor, anchor_id)
|
|
if anchor is None:
|
|
return False
|
|
s.execute(
|
|
update(Annotation)
|
|
.where(Annotation.resolved_anchor_id == anchor_id)
|
|
.values(resolved_anchor_id=None)
|
|
)
|
|
s.delete(anchor)
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Events
|
|
# ---------------------------------------------------------------------------
|
|
def add_event(
|
|
t_global_s: float,
|
|
event_type: str,
|
|
source: str,
|
|
duration_s: float | None = None,
|
|
confidence: float | None = None,
|
|
description: str | None = None,
|
|
) -> Event:
|
|
with session_scope() as s:
|
|
event = Event(
|
|
t_global_s=t_global_s,
|
|
duration_s=duration_s,
|
|
event_type=event_type,
|
|
confidence=confidence,
|
|
description=description,
|
|
source=source,
|
|
)
|
|
s.add(event)
|
|
s.flush()
|
|
s.refresh(event)
|
|
return event
|
|
|
|
|
|
def get_events() -> list[Event]:
|
|
with session_scope() as s:
|
|
return list(s.scalars(select(Event).order_by(Event.t_global_s)))
|
|
|
|
|
|
def clear_events(source: str | None = None) -> int:
|
|
"""Delete events, optionally only those from a given ``source``. Returns count deleted."""
|
|
with session_scope() as s:
|
|
stmt = delete(Event)
|
|
if source is not None:
|
|
stmt = stmt.where(Event.source == source)
|
|
result = s.execute(stmt)
|
|
return int(result.rowcount or 0)
|
|
|
|
|
|
def get_event(event_id: int) -> Event | None:
|
|
with session_scope() as s:
|
|
return s.get(Event, event_id)
|
|
|
|
|
|
def update_event(
|
|
event_id: int,
|
|
event_type: str | None = None,
|
|
source: str | None = None,
|
|
description: str | None = None,
|
|
confidence: float | None = None,
|
|
) -> Event:
|
|
"""Patch an existing event in place (M8 correction). Only provided fields change.
|
|
|
|
Returns the updated row; raises ``KeyError`` if the id is unknown.
|
|
"""
|
|
with session_scope() as s:
|
|
event = s.get(Event, event_id)
|
|
if event is None:
|
|
raise KeyError(f"no event with id={event_id}")
|
|
if event_type is not None:
|
|
event.event_type = event_type
|
|
if source is not None:
|
|
event.source = source
|
|
if description is not None:
|
|
event.description = description
|
|
if confidence is not None:
|
|
event.confidence = confidence
|
|
s.flush()
|
|
s.refresh(event)
|
|
return event
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Saved camera paths (phase 5 / M16)
|
|
# ---------------------------------------------------------------------------
|
|
def add_path(name: str, path_json: str) -> SavedPath:
|
|
with session_scope() as s:
|
|
path = SavedPath(name=name, path_json=path_json, created_at=_now_iso())
|
|
s.add(path)
|
|
s.flush()
|
|
s.refresh(path)
|
|
return path
|
|
|
|
|
|
def get_paths() -> list[SavedPath]:
|
|
with session_scope() as s:
|
|
return list(s.scalars(select(SavedPath).order_by(SavedPath.id)))
|
|
|
|
|
|
def get_path(path_id: int) -> SavedPath | None:
|
|
with session_scope() as s:
|
|
return s.get(SavedPath, path_id)
|
|
|
|
|
|
def delete_path(path_id: int) -> bool:
|
|
"""Delete a saved path. Returns ``True`` if a row was deleted, ``False`` if unknown."""
|
|
with session_scope() as s:
|
|
path = s.get(SavedPath, path_id)
|
|
if path is None:
|
|
return False
|
|
s.delete(path)
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Annotations
|
|
# ---------------------------------------------------------------------------
|
|
def add_annotation(
|
|
video_id: int,
|
|
t_video_s: float,
|
|
x0: float,
|
|
y0: float,
|
|
x1: float,
|
|
y1: float,
|
|
event_id: int | None = None,
|
|
resolved_anchor_id: int | None = None,
|
|
) -> Annotation:
|
|
with session_scope() as s:
|
|
annotation = Annotation(
|
|
video_id=video_id,
|
|
t_video_s=t_video_s,
|
|
x0=x0, y0=y0, x1=x1, y1=y1,
|
|
event_id=event_id,
|
|
resolved_anchor_id=resolved_anchor_id,
|
|
)
|
|
s.add(annotation)
|
|
s.flush()
|
|
s.refresh(annotation)
|
|
return annotation
|
|
|
|
|
|
def get_annotations(
|
|
video_id: int | None = None, event_id: int | None = None
|
|
) -> list[Annotation]:
|
|
with session_scope() as s:
|
|
stmt = select(Annotation).order_by(Annotation.id)
|
|
if video_id is not None:
|
|
stmt = stmt.where(Annotation.video_id == video_id)
|
|
if event_id is not None:
|
|
stmt = stmt.where(Annotation.event_id == event_id)
|
|
return list(s.scalars(stmt))
|
|
|
|
|
|
def set_annotation_anchor(annotation_id: int, anchor_id: int) -> None:
|
|
with session_scope() as s:
|
|
annotation = s.get(Annotation, annotation_id)
|
|
if annotation is None:
|
|
raise KeyError(f"no annotation with id={annotation_id}")
|
|
annotation.resolved_anchor_id = anchor_id
|