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>
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
"""Moment detection + pluggable AI classification (spec M7, lane D).
|
|
|
|
Two stages: cheap audio candidates first, then a vision LLM on candidates only.
|
|
|
|
This module defines the **FROZEN classifier contract** — :class:`MomentClassification`
|
|
(Pydantic model) and :class:`MomentClassifier` (Protocol) — exactly as spec M7. Lanes and
|
|
integration depend on these shapes. Everything else here is a STUB that lane D fills:
|
|
candidate detection, the provider classes, provider selection, and the ``run_events``
|
|
entrypoint (called by both ``cli.py`` and ``api.py``'s ``POST /api/events/detect``).
|
|
|
|
Provider selection (lane D): ``FESTIVAL4D_CLASSIFIER=gemini|claude|local`` (default
|
|
``gemini``). Inputs prepared once per candidate: a ~3 s MP4 clip around ``t_global`` from
|
|
the best-covering video and 6 sampled JPEGs for providers without video input. Common
|
|
behavior: catch exceptions per candidate (one failure never aborts the batch); if the
|
|
selected provider is unconfigured, run candidate detection only (``event_type='candidate'``)
|
|
and log that classification was skipped.
|
|
|
|
The module imports with only ``pydantic`` present — provider SDKs (``google-genai``,
|
|
``anthropic``, ``openai``) are imported lazily inside the provider bodies, never at load.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Literal, Protocol, runtime_checkable
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
log = logging.getLogger("festival4d.events_ai")
|
|
|
|
# The closed set of moment classes (spec M7). Lane C colors timeline markers by these.
|
|
EventType = Literal[
|
|
"bass_drop", "pyro", "confetti", "crowd_wave",
|
|
"artist_moment", "light_show", "quiet_moment", "other",
|
|
]
|
|
EVENT_TYPES: tuple[str, ...] = (
|
|
"bass_drop", "pyro", "confetti", "crowd_wave",
|
|
"artist_moment", "light_show", "quiet_moment", "other",
|
|
)
|
|
|
|
|
|
class MomentClassification(BaseModel):
|
|
"""Structured classifier output (FROZEN CONTRACT, spec M7)."""
|
|
|
|
event_type: EventType
|
|
confidence: float = Field(ge=0.0, le=1.0) # 0..1
|
|
description: str # one sentence, human-readable
|
|
|
|
|
|
@runtime_checkable
|
|
class MomentClassifier(Protocol):
|
|
"""Interchangeable classifier provider (FROZEN CONTRACT, spec M7)."""
|
|
|
|
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
|
|
"""Classify one candidate moment from a short clip + sampled frames."""
|
|
...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider stubs (lane D fills bodies; classifiers import their SDK lazily).
|
|
# ---------------------------------------------------------------------------
|
|
class GeminiClassifier:
|
|
"""Default provider — Gemini 2.5 Flash, native video input (``GEMINI_API_KEY``)."""
|
|
|
|
model = "gemini-2.5-flash"
|
|
|
|
def __init__(self) -> None:
|
|
raise NotImplementedError("lane D (M7): implement GeminiClassifier")
|
|
|
|
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
|
|
raise NotImplementedError("lane D (M7): implement GeminiClassifier.classify")
|
|
|
|
|
|
class ClaudeClassifier:
|
|
"""Optional provider — Claude Opus 4.8, 6 frames as images (``ANTHROPIC_API_KEY``).
|
|
|
|
Uses ``client.messages.parse(..., thinking={'type': 'adaptive'},
|
|
output_format=MomentClassification)``; do **not** pass ``temperature`` (rejected on
|
|
Opus 4.8). Model id ``claude-opus-4-8`` (never date-suffixed).
|
|
"""
|
|
|
|
model = "claude-opus-4-8"
|
|
|
|
def __init__(self) -> None:
|
|
raise NotImplementedError("lane D (M7): implement ClaudeClassifier")
|
|
|
|
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
|
|
raise NotImplementedError("lane D (M7): implement ClaudeClassifier.classify")
|
|
|
|
|
|
class LocalClassifier:
|
|
"""Offline/free provider — any OpenAI-compatible vision endpoint (Ollama/LM Studio/
|
|
OpenRouter). Config: ``FESTIVAL4D_OPENAI_BASE_URL``, ``FESTIVAL4D_OPENAI_KEY``,
|
|
``FESTIVAL4D_OPENAI_MODEL``. Sends the 6 frames as image parts; validates JSON with one
|
|
retry on failure.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
raise NotImplementedError("lane D (M7): implement LocalClassifier")
|
|
|
|
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
|
|
raise NotImplementedError("lane D (M7): implement LocalClassifier.classify")
|
|
|
|
|
|
def get_classifier(name: str | None = None) -> MomentClassifier | None:
|
|
"""Select a provider by name or ``FESTIVAL4D_CLASSIFIER`` (default ``gemini``).
|
|
|
|
Returns a ready classifier, or ``None`` if the selected provider is unconfigured (no
|
|
key/endpoint) — callers then run candidates-only. STUB — lane D implements selection.
|
|
"""
|
|
_ = name or os.environ.get("FESTIVAL4D_CLASSIFIER", "gemini")
|
|
raise NotImplementedError("lane D (M7): implement get_classifier")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Candidate detection + orchestration (lane D fills bodies).
|
|
# ---------------------------------------------------------------------------
|
|
def detect_candidates(audio: "object", sr: int) -> list[float]:
|
|
"""Audio candidate moments on the reference track (spec M7, lane D).
|
|
|
|
Compute RMS energy + spectral-flux onset strength (librosa); return the times
|
|
(reference-local seconds) of peaks that are local maxima over a 10 s neighborhood and
|
|
above the 90th percentile. Verified against the fixture's ground-truth pulse times
|
|
(±0.5 s).
|
|
"""
|
|
raise NotImplementedError("lane D (M7): implement detect_candidates")
|
|
|
|
|
|
def prepare_inputs(t_global_s: float) -> tuple[Path, list[Path]]:
|
|
"""Prepare classifier inputs for a candidate: a ~3 s MP4 clip + 6 sampled JPEGs (M7)."""
|
|
raise NotImplementedError("lane D (M7): implement prepare_inputs")
|
|
|
|
|
|
def run_events(t_global_s: float | None = None, window_s: float | None = None) -> dict:
|
|
"""Detect audio candidates and (if a provider is configured) classify them.
|
|
|
|
Entrypoint for ``python -m festival4d events`` and ``POST /api/events/detect``. Inserts
|
|
events via ``db`` (``source='audio_auto'``; classified ones updated to ``source='ai'``).
|
|
Optionally restrict to a single window around ``t_global_s`` of width ``window_s``.
|
|
Returns a summary dict. Per-candidate exceptions are isolated (spec M7).
|
|
"""
|
|
raise NotImplementedError("lane D (M7): implement run_events")
|