""" Tests that reproduce and verify the fix for the scene-equilibrium drift bug. Bug: within a single scene, the crop position should be static at the equilibrium point. Two code paths were moving it after the subject briefly left the safe zone and then returned: 1. _generate_keyframes_from_detections – applied a rubber-band pull toward equilibrium instead of snapping back, causing gradual per-keyframe drift. 2. _process_segment_unified – held `current_crop_x` (already displaced) instead of equilibrium, so the displaced position became permanent. The fix: in both paths, when the subject is inside the safe zone, always use (equilibrium_x, equilibrium_y) directly. """ import pytest from fansifter_clipper.models import Detection from fansifter_clipper.reframing import Reframer # ─── helpers ──────────────────────────────────────────────────────────────── FRAME_W = 1920 FRAME_H = 2560 # taller than crop so y clamping never masks the bug CROP_W = 720 CROP_H = 1280 EQ_X = 600 # equilibrium crop top-left x EQ_Y = 640 # equilibrium crop top-left y # Centre of the equilibrium crop window (always inside the safe zone) SUBJECT_CENTER_X = EQ_X + CROP_W // 2 # 960 SUBJECT_CENTER_Y = EQ_Y + CROP_H // 2 # 1280 def det(time: float, cx: int = SUBJECT_CENTER_X, cy: int = SUBJECT_CENTER_Y, confidence: float = 0.9) -> Detection: return Detection(time=time, center_x=cx, center_y=cy, confidence=confidence, detection_method="mediapipe_face") def gen_keyframes(detections, reframer=None): """Helper: call _generate_keyframes_from_detections with standard params.""" if reframer is None: reframer = Reframer() return reframer._generate_keyframes_from_detections( detections=detections, equilibrium_x=EQ_X, equilibrium_y=EQ_Y, crop_width=CROP_W, crop_height=CROP_H, frame_width=FRAME_W, frame_height=FRAME_H, keyframe_interval=0.9, # slightly below 1.0 so every 1-s detection is captured ) # ─── reproducing tests (fail before fix, pass after) ──────────────────────── class TestEquilibriumDriftBug: """Reproduce the bug: crop drifts inside a scene after a brief safe-zone exit.""" def test_crop_returns_to_equilibrium_immediately_after_safe_zone_reentry(self): """ Subject exits the safe zone for one keyframe then comes back. All keyframes AFTER the re-entry must be exactly at equilibrium. Before the fix, the rubber-band produced e.g. x=656, 639, 627, … """ # Subject exits safe zone at t=1 (x=1400 >> safe_right ≈ 1176) detections = [ det(0.0), # safe zone det(1.0, cx=1400), # exits safe zone → crop shifts right det(2.0), # returns to safe zone det(3.0), # still safe det(4.0), # still safe ] keyframes = gen_keyframes(detections) post_reentry = [kf for kf in keyframes if kf.time >= 2.0] assert len(post_reentry) >= 3, "Need at least 3 keyframes after re-entry" for kf in post_reentry: assert kf.x == EQ_X, ( f"t={kf.time}: x={kf.x} drifted from equilibrium {EQ_X}. " f"Rubber-band drift bug still present." ) assert kf.y == EQ_Y, ( f"t={kf.time}: y={kf.y} drifted from equilibrium {EQ_Y}." ) def test_crop_stays_at_equilibrium_when_subject_always_in_safe_zone(self): """ Subject is always in the safe zone. Every keyframe should sit exactly at equilibrium with zero movement. """ detections = [det(float(t)) for t in range(6)] keyframes = gen_keyframes(detections) assert len(keyframes) >= 3 for kf in keyframes: assert kf.x == EQ_X, f"t={kf.time}: x={kf.x} != equilibrium {EQ_X}" assert kf.y == EQ_Y, f"t={kf.time}: y={kf.y} != equilibrium {EQ_Y}" def test_multiple_excursions_always_return_to_equilibrium(self): """ Subject repeatedly exits and re-enters the safe zone. Each time it comes back the crop must snap to equilibrium, not drift. """ detections = [ det(0.0), # safe det(1.0, cx=1400), # exit det(2.0), # back → must be EQ_X det(3.0, cx=1500), # exit again det(4.0), # back → must be EQ_X det(5.0), # safe → must be EQ_X ] keyframes = gen_keyframes(detections) # Check the "back in safe zone" keyframes specifically safe_times = {2.0, 4.0, 5.0} for kf in keyframes: if kf.time in safe_times: assert kf.x == EQ_X, ( f"t={kf.time}: x={kf.x} should snap back to equilibrium {EQ_X}" ) def test_out_of_zone_keyframe_is_not_at_equilibrium(self): """ Verify that while the subject IS outside the safe zone the crop legitimately moves (i.e. the fix doesn't suppress valid corrections). """ detections = [ det(0.0), # safe → equilibrium det(1.0, cx=1400), # exits → should shift right of EQ_X ] keyframes = gen_keyframes(detections) exit_kf = next(kf for kf in keyframes if kf.time == 1.0) # Crop must have moved toward the subject (away from equilibrium) assert exit_kf.x > EQ_X, ( f"Expected crop to shift right of equilibrium ({EQ_X}) " f"when subject exits safe zone, got x={exit_kf.x}" ) def test_drift_amount_with_current_code(self): """ Quantify the drift to make the regression concrete. Trace for current (buggy) code: t=0: x=600 (safe, at equilibrium) t=1: x=680 (exit: subject at 1400, dx capped at 80) t=2: x=656 (safe re-entry: rubber-band 30% of 80-px offset) t=3: x=639 (rubber-band: 30% of 56-px offset) t=4: x=627 (rubber-band: 30% of 39-px offset) After the fix all post-re-entry frames must be x=600. """ detections = [ det(0.0), det(1.0, cx=1400), det(2.0), det(3.0), det(4.0), ] keyframes = gen_keyframes(detections) post = {kf.time: kf.x for kf in keyframes if kf.time >= 2.0} # The fix must produce exact equilibrium for all post-reentry frames for t, x in post.items(): assert x == EQ_X, ( f"t={t}: x={x} should be {EQ_X} (equilibrium). " f"If this fails at 656/639/627 the rubber-band drift bug is present." )