generate_path() fills the foundation2 stub with the deterministic v1 rules: top-N events by confidence (ties -> earlier, then shot chronologically); per event, the registered camera whose pose track is nearest the event time; keyframes at t_event - lead_s (clamped >= 0) and t_event + duration; nearest-beat snapping when beats.json exists (missing/corrupt file -> no snapping); FOV = 2*atan(h/(2*fy)) like scene3d.snapTo. Every pose goes through the frozen geometry.colmap_to_threejs and the camPath quaternion is emitted in Three.js [x,y,z,w] order (pitfall #1 — locked by test). Degradation: no events / no registered poses -> valid empty camPath with a note; the response always round-trips through camPath.fromJSON. frontend/src/director.js wires the pre-wired hidden 🎬 button: POST /api/director, camPath.fromJSON(response), auto-play via the existing #btn-path flow; empty path or fetch failure surfaces on the button and recovers — never a crash. Acceptance (test_director.py, 14 tests): structure-locked against the frozen camPath shape (exact key sets, unit quats, monotone times); top-3 grid == [1,4,8,11,14] incl. shared-keyframe dedupe; conversion matches colmap_to_threejs + mat_to_quat exactly; snapping puts every key on the beat grid; unregistered poses excluded; API route + CLI dispatch real output. Suite 148 passed; frontend build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
6.3 KiB
Python
153 lines
6.3 KiB
Python
"""Auto-director: events -> keyframed camera path (spec M11, lane E).
|
|
|
|
Contract (frozen at foundation2 merge — phase-5 contract #2): :func:`generate_path`
|
|
returns a **camPath-compatible** dict::
|
|
|
|
{"version": 1,
|
|
"keyframes": [{"t_global": float, "pos": [x, y, z], "quat": [x, y, z, w], "fov": float}]}
|
|
|
|
Poses are **Three.js scene space**, quaternion order **[x, y, z, w]** — convert from the
|
|
COLMAP rows via :func:`festival4d.geometry.colmap_to_threejs`, never inline (pitfall #1).
|
|
|
|
Deterministic v1 rules — no ML: take the top-N events by confidence (ties -> earlier);
|
|
for each, choose the registered camera with a pose nearest the event time; keyframes at
|
|
``t_event - lead_s`` and ``t_event + duration``; when ``config.BEATS_JSON`` exists, snap
|
|
keyframe times to the nearest beat (no beats file -> no snapping — degrade, don't block);
|
|
FOV from that camera's intrinsics (``2 * atan(height / (2 * fy))`` in degrees, the same
|
|
formula as ``scene3d.snapTo``).
|
|
|
|
Degradation (house style): no events -> valid empty path; no registered poses -> valid
|
|
empty path with a clear ``note`` (the response always round-trips through
|
|
``camPath.fromJSON``); no beats.json -> same path, just unsnapped. Never a crash.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import math
|
|
|
|
from festival4d import config, db
|
|
from festival4d.geometry import colmap_to_threejs, mat_to_quat
|
|
|
|
log = logging.getLogger("festival4d.director")
|
|
|
|
DEFAULT_EVENT_DURATION_S = 1.0 # used when an event has no duration_s
|
|
|
|
|
|
def _empty(note: str | None = None) -> dict:
|
|
path: dict = {"version": 1, "keyframes": []}
|
|
if note:
|
|
path["note"] = note
|
|
log.info("director: %s -> empty path", note)
|
|
return path
|
|
|
|
|
|
def _load_beats() -> list[float]:
|
|
"""The beat grid from beats.json, if valid; otherwise [] (no snapping — pitfall #5)."""
|
|
if not config.BEATS_JSON.exists():
|
|
return []
|
|
try:
|
|
beats = json.loads(config.BEATS_JSON.read_text()).get("beats_s", [])
|
|
return sorted(float(b) for b in beats)
|
|
except (ValueError, TypeError, AttributeError) as exc:
|
|
log.warning("director: unreadable beats.json (%s) — skipping beat snap", exc)
|
|
return []
|
|
|
|
|
|
def _snap_to_beat(t: float, beats: list[float]) -> float:
|
|
"""Nearest beat to t (beats sorted ascending); t unchanged when the grid is empty."""
|
|
if not beats:
|
|
return t
|
|
return min(beats, key=lambda b: abs(b - t))
|
|
|
|
|
|
def _nearest_pose(poses: list, t_video: float):
|
|
"""The registered pose nearest a local time, and its |dt|. Poses are t_video_s-sorted."""
|
|
best = min(poses, key=lambda p: abs(p.t_video_s - t_video))
|
|
return best, abs(best.t_video_s - t_video)
|
|
|
|
|
|
def _keyframe(pose, video, t_global: float) -> dict:
|
|
"""Build one frozen-shape keyframe from a COLMAP pose row (contract #2).
|
|
|
|
The COLMAP [w,x,y,z] world->camera pose goes through the frozen
|
|
``geometry.colmap_to_threejs`` (never inline — pitfall #1); the resulting Three.js
|
|
world rotation matrix becomes the camPath quaternion, reordered to Three.js [x,y,z,w].
|
|
"""
|
|
position, rotation = colmap_to_threejs(
|
|
[pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz]
|
|
)
|
|
qw, qx, qy, qz = mat_to_quat(rotation) # [w,x,y,z] of the *Three.js* world rotation
|
|
fov_deg = math.degrees(2.0 * math.atan(video.height / (2.0 * pose.fy))) # scene3d.snapTo
|
|
return {
|
|
"t_global": float(t_global),
|
|
"pos": [float(v) for v in position],
|
|
"quat": [float(qx), float(qy), float(qz), float(qw)], # Three.js order [x,y,z,w]
|
|
"fov": float(fov_deg),
|
|
}
|
|
|
|
|
|
def generate_path(top_n: int = 8, lead_s: float = 2.0) -> dict:
|
|
"""Build a camPath JSON dict from the detected events (see module doc)."""
|
|
events = db.get_events()
|
|
if not events:
|
|
return _empty("no events to direct (run `python -m festival4d events`)")
|
|
|
|
videos = db.get_videos()
|
|
# Registered poses per video (interpolated rows are excluded: the director only cuts
|
|
# to cameras that were actually solved at that moment).
|
|
tracks: dict[int, list] = {}
|
|
for video in videos:
|
|
registered = [p for p in db.get_poses(video.id) if p.registered]
|
|
if registered:
|
|
tracks[video.id] = registered
|
|
if not tracks:
|
|
return _empty("no registered camera poses (run `python -m festival4d reconstruct`)")
|
|
video_by_id = {v.id: v for v in videos}
|
|
|
|
# Top-N events by confidence, ties -> earlier; then shoot them in chronological order.
|
|
ranked = sorted(events, key=lambda e: (-(e.confidence or 0.0), e.t_global_s, e.id))
|
|
chosen = sorted(ranked[: max(0, int(top_n))], key=lambda e: (e.t_global_s, e.id))
|
|
|
|
beats = _load_beats()
|
|
|
|
keyframes: list[dict] = []
|
|
for event in chosen:
|
|
# The registered camera whose pose track comes closest to the event time.
|
|
best_id, best_dt = None, None
|
|
for vid, poses in sorted(tracks.items()): # sorted -> deterministic tie-break
|
|
video = video_by_id[vid]
|
|
tv_event = config.t_video_from_global(
|
|
event.t_global_s, video.offset_ms or 0.0, video.drift_ppm or 0.0
|
|
)
|
|
_, dt = _nearest_pose(poses, tv_event)
|
|
if best_dt is None or dt < best_dt:
|
|
best_id, best_dt = vid, dt
|
|
video = video_by_id[best_id]
|
|
poses = tracks[best_id]
|
|
|
|
duration = event.duration_s if event.duration_s is not None else DEFAULT_EVENT_DURATION_S
|
|
for t_key in (event.t_global_s - lead_s, event.t_global_s + duration):
|
|
t_key = _snap_to_beat(max(0.0, t_key), beats)
|
|
tv_key = config.t_video_from_global(
|
|
t_key, video.offset_ms or 0.0, video.drift_ppm or 0.0
|
|
)
|
|
pose, _ = _nearest_pose(poses, tv_key)
|
|
keyframes.append(_keyframe(pose, video, t_key))
|
|
|
|
# Chronological, and collapse keyframes that landed on the same instant (adjacent events
|
|
# snapping to a shared beat) — camPath needs a monotone timeline.
|
|
keyframes.sort(key=lambda k: k["t_global"])
|
|
deduped: list[dict] = []
|
|
for kf in keyframes:
|
|
if deduped and abs(kf["t_global"] - deduped[-1]["t_global"]) < 1e-6:
|
|
continue
|
|
deduped.append(kf)
|
|
|
|
log.info(
|
|
"director: %d event(s) -> %d keyframe(s)%s",
|
|
len(chosen), len(deduped), " (beat-snapped)" if beats else "",
|
|
)
|
|
return {"version": 1, "keyframes": deduped}
|