"""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