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>
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""M11 acceptance tests (lane E): deterministic auto-director -> frozen camPath JSON.
|
|
|
|
Runs on the synthetic fixture WITHOUT ffmpeg (poses + events + DB only): generate_path
|
|
never touches media files. The synthetic seed events have known times/confidences, so
|
|
event selection, keyframe placement, beat snapping, and the pose conversion are all
|
|
checked against ground truth. The quaternion test locks the [x,y,z,w] Three.js order via
|
|
the frozen geometry.colmap_to_threejs — this repo's #1 historical bug source.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
# Synthetic seed events (synthetic.SEED_EVENTS): (t_global, type, confidence)
|
|
# 3.0 bass_drop .94 | 6.0 crowd_wave .72 | 8.0 quiet .61 | 10.0 pyro .88
|
|
# 13.0 light_show .90 | 17.0 confetti .81 | 19.0 artist .77
|
|
TOP3_TIMES = [3.0, 10.0, 13.0] # confidences .94, .88, .90 -> top 3
|
|
KEYFRAME_KEYS = {"t_global", "pos", "quat", "fov"}
|
|
|
|
|
|
@pytest.fixture()
|
|
def project():
|
|
"""Fresh synthetic project (no ffmpeg) per test — several tests mutate the DB."""
|
|
from festival4d import config, synthetic
|
|
|
|
synthetic.build(run_ffmpeg=False)
|
|
config.BEATS_JSON.unlink(missing_ok=True) # unsnapped by default; tests opt in
|
|
yield config
|
|
config.BEATS_JSON.unlink(missing_ok=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Structure lock: the frozen camPath shape (contract #2).
|
|
# ---------------------------------------------------------------------------
|
|
def test_output_matches_frozen_campath_shape(project):
|
|
from festival4d import director
|
|
|
|
path = director.generate_path()
|
|
assert set(path) == {"version", "keyframes"} # exact-set lock (no stray fields on success)
|
|
assert path["version"] == 1
|
|
assert len(path["keyframes"]) > 0
|
|
for kf in path["keyframes"]:
|
|
assert set(kf) == KEYFRAME_KEYS # exact-set lock per keyframe
|
|
assert isinstance(kf["t_global"], float)
|
|
assert len(kf["pos"]) == 3 and all(isinstance(v, float) for v in kf["pos"])
|
|
assert len(kf["quat"]) == 4 and all(isinstance(v, float) for v in kf["quat"])
|
|
assert abs(math.hypot(*kf["quat"][:3], kf["quat"][3]) - 1.0) < 1e-6 # unit quaternion
|
|
assert 10.0 < kf["fov"] < 120.0
|
|
times = [kf["t_global"] for kf in path["keyframes"]]
|
|
assert times == sorted(times)
|
|
assert len(set(times)) == len(times) # strictly monotone (dedup after snapping)
|
|
# JSON-serializable end to end (the API returns this dict verbatim).
|
|
json.dumps(path)
|
|
|
|
|
|
def test_covers_the_top_n_events_with_lead_and_duration(project):
|
|
from festival4d import director
|
|
|
|
path = director.generate_path(top_n=3, lead_s=2.0)
|
|
times = [kf["t_global"] for kf in path["keyframes"]]
|
|
# Each top-3 event contributes keys at t-2.0 and t+duration (1.0 in the fixture):
|
|
# 3.0 -> {1.0, 4.0}; 10.0 -> {8.0, 11.0}; 13.0 -> {11.0, 14.0}. The shared 11.0
|
|
# collapses to one keyframe -> exactly 5, and every event is bracketed.
|
|
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
|
|
for t_event in TOP3_TIMES:
|
|
assert any(t <= t_event for t in times) and any(t >= t_event for t in times)
|
|
|
|
|
|
def test_full_top_n_covers_every_event(project):
|
|
from festival4d import director
|
|
|
|
path = director.generate_path(top_n=8, lead_s=2.0) # fixture has 7 events
|
|
times = [kf["t_global"] for kf in path["keyframes"]]
|
|
for t_event in [3.0, 6.0, 8.0, 10.0, 13.0, 17.0, 19.0]:
|
|
assert min(abs(t - (t_event - 2.0)) for t in times) < 1e-9
|
|
assert min(abs(t - (t_event + 1.0)) for t in times) < 1e-9
|
|
|
|
|
|
def test_confidence_ties_break_to_the_earlier_event(project):
|
|
from festival4d import db, director
|
|
|
|
db.clear_events()
|
|
db.add_event(t_global_s=15.0, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
|
|
db.add_event(t_global_s=5.0, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
|
|
path = director.generate_path(top_n=1, lead_s=2.0)
|
|
assert [kf["t_global"] for kf in path["keyframes"]] == [3.0, 6.0] # the t=5 event won
|
|
|
|
|
|
def test_lead_time_clamps_at_zero(project):
|
|
from festival4d import db, director
|
|
|
|
db.clear_events()
|
|
db.add_event(t_global_s=0.5, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
|
|
path = director.generate_path(top_n=1, lead_s=2.0)
|
|
assert [kf["t_global"] for kf in path["keyframes"]] == [0.0, 1.5]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pose conversion: through the frozen geometry.colmap_to_threejs, quat order [x,y,z,w].
|
|
# ---------------------------------------------------------------------------
|
|
def test_keyframe_pose_matches_frozen_conversion(project):
|
|
from festival4d import db, director
|
|
from festival4d.geometry import colmap_to_threejs, mat_to_quat, quat_to_mat
|
|
|
|
path = director.generate_path(top_n=1, lead_s=2.0) # top event: bass_drop @ 3.0
|
|
kf = path["keyframes"][0] # t_global = 1.0
|
|
|
|
# Every synthetic event time is an exact cam0 pose time (offset 0), so the chosen
|
|
# camera is video 1 and the keyframe pose is cam0's registered row at t_video_s=1.0.
|
|
pose = next(p for p in db.get_poses(1) if abs(p.t_video_s - 1.0) < 1e-9)
|
|
expected_pos, expected_rot = colmap_to_threejs(
|
|
[pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz]
|
|
)
|
|
assert np.allclose(kf["pos"], expected_pos, atol=1e-9)
|
|
|
|
# Quaternion order lock: reordering [x,y,z,w] -> [w,x,y,z] must reproduce the Three.js
|
|
# world rotation matrix (up to the quaternion double cover).
|
|
x, y, z, w = kf["quat"]
|
|
assert np.allclose(quat_to_mat([w, x, y, z]), expected_rot, atol=1e-9)
|
|
expected_q = mat_to_quat(expected_rot) # [w,x,y,z], canonical w >= 0
|
|
q_out = np.array([w, x, y, z])
|
|
if np.dot(q_out, expected_q) < 0:
|
|
q_out = -q_out
|
|
assert np.allclose(q_out, expected_q, atol=1e-9)
|
|
|
|
# FOV from the chosen camera's intrinsics: 2*atan(height / (2*fy)) in degrees.
|
|
video = db.get_video(1)
|
|
expected_fov = math.degrees(2.0 * math.atan(video.height / (2.0 * pose.fy)))
|
|
assert kf["fov"] == pytest.approx(expected_fov, abs=1e-9)
|
|
|
|
|
|
def test_unregistered_poses_are_excluded(project):
|
|
from festival4d import db, director
|
|
|
|
# Mark every cam0 pose unregistered: the director must cut to cam1/cam2 instead.
|
|
poses = db.get_poses(1)
|
|
db.set_poses(1, [
|
|
{"frame_idx": p.frame_idx, "t_video_s": p.t_video_s,
|
|
"qw": p.qw, "qx": p.qx, "qy": p.qy, "qz": p.qz,
|
|
"tx": p.tx, "ty": p.ty, "tz": p.tz,
|
|
"fx": p.fx, "fy": p.fy, "cx": p.cx, "cy": p.cy, "registered": False}
|
|
for p in poses
|
|
])
|
|
path = director.generate_path(top_n=1, lead_s=2.0)
|
|
assert len(path["keyframes"]) == 2
|
|
cam0_centers = {
|
|
tuple(np.round(np.asarray(_center(p)), 6)) for p in poses
|
|
}
|
|
for kf in path["keyframes"]:
|
|
assert tuple(np.round(kf["pos"], 6)) not in cam0_centers
|
|
|
|
|
|
def _center(pose):
|
|
from festival4d.geometry import colmap_to_threejs
|
|
|
|
pos, _ = colmap_to_threejs([pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz])
|
|
return pos
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Beat snapping (M10 -> M11), and degradation without it.
|
|
# ---------------------------------------------------------------------------
|
|
def test_keyframes_snap_to_beats_when_beats_exist(project):
|
|
from festival4d import director
|
|
|
|
grid = [round(k * 0.75, 4) for k in range(0, 28)] # 0.75 s grid != the keyframe times
|
|
project.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
project.BEATS_JSON.write_text(json.dumps({
|
|
"tempo_bpm": 80.0, "beats_s": grid, "onsets": [],
|
|
"generated_by": "festival4d.audio_features",
|
|
}))
|
|
path = director.generate_path(top_n=3, lead_s=2.0)
|
|
times = [kf["t_global"] for kf in path["keyframes"]]
|
|
assert len(times) > 0
|
|
assert all(any(abs(t - b) < 1e-9 for b in grid) for t in times) # every key ON a beat
|
|
assert times != [1.0, 4.0, 8.0, 11.0, 14.0] # actually moved vs the unsnapped grid
|
|
|
|
|
|
def test_no_beats_file_means_no_snapping(project):
|
|
from festival4d import director
|
|
|
|
assert not project.BEATS_JSON.exists()
|
|
times = [kf["t_global"] for kf in director.generate_path(top_n=3, lead_s=2.0)["keyframes"]]
|
|
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
|
|
|
|
|
|
def test_corrupt_beats_file_degrades_to_unsnapped(project):
|
|
from festival4d import director
|
|
|
|
project.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
project.BEATS_JSON.write_text("{ not json")
|
|
times = [kf["t_global"] for kf in director.generate_path(top_n=3, lead_s=2.0)["keyframes"]]
|
|
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Degradation: empty inputs -> valid empty path (still camPath.fromJSON-safe).
|
|
# ---------------------------------------------------------------------------
|
|
def test_no_events_yields_valid_empty_path(project):
|
|
from festival4d import db, director
|
|
|
|
db.clear_events()
|
|
path = director.generate_path()
|
|
assert path["version"] == 1 and path["keyframes"] == []
|
|
assert "events" in path["note"]
|
|
|
|
|
|
def test_no_registered_poses_yields_valid_empty_path_with_note(project):
|
|
from festival4d import db, director
|
|
|
|
for video in db.get_videos():
|
|
db.set_poses(video.id, [])
|
|
path = director.generate_path()
|
|
assert path["version"] == 1 and path["keyframes"] == []
|
|
assert "poses" in path["note"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch: the API route and CLI now run the real implementation.
|
|
# ---------------------------------------------------------------------------
|
|
def test_api_director_route_returns_real_path(project):
|
|
from fastapi.testclient import TestClient
|
|
|
|
from festival4d import api
|
|
|
|
with TestClient(api.app) as client:
|
|
data = client.post("/api/director", json={"top_n": 3, "lead_s": 2.0}).json()
|
|
assert data["version"] == 1
|
|
assert [kf["t_global"] for kf in data["keyframes"]] == [1.0, 4.0, 8.0, 11.0, 14.0]
|
|
assert all(set(kf) == KEYFRAME_KEYS for kf in data["keyframes"])
|
|
|
|
|
|
def test_cli_direct_prints_campath_json(project, capsys):
|
|
from festival4d import cli
|
|
|
|
assert cli.main(["direct", "--top-n", "2", "--lead-s", "1.0"]) == 0
|
|
printed = json.loads(capsys.readouterr().out)
|
|
assert printed["version"] == 1 and len(printed["keyframes"]) > 0
|