"""Unit tests for CropInterpolator in frame_processor.py.""" import pytest from fansifter_clipper.frame_processor import CropInterpolator from fansifter_clipper.models import CropKeyframe def make_kf(time, x, y, scene_change=False, width=720, height=1280): return CropKeyframe(time=time, x=x, y=y, width=width, height=height, scene_change=scene_change) class TestCropInterpolatorInit: def test_requires_at_least_one_keyframe(self): with pytest.raises(ValueError, match="Need at least 1 keyframe"): CropInterpolator([], segment_start=0.0) def test_single_keyframe_constant(self): kf = make_kf(0.0, 100, 50) interp = CropInterpolator([kf], segment_start=0.0) assert interp.interpolation_method == "constant" assert interp.crop_width == 720 assert interp.crop_height == 1280 def test_multiple_keyframes_linear(self): kfs = [make_kf(0.0, 100, 50), make_kf(5.0, 200, 150)] interp = CropInterpolator(kfs, segment_start=0.0) assert interp.interpolation_method == "linear" def test_segments_split_at_scene_change(self): kfs = [ make_kf(0.0, 100, 50), make_kf(2.0, 110, 60), make_kf(4.0, 800, 600, scene_change=True), make_kf(6.0, 810, 610), ] interp = CropInterpolator(kfs, segment_start=0.0) assert interp.interpolators is not None assert len(interp.interpolators) == 2 class TestCropInterpolatorSplitIntoSegments: def test_no_scene_changes(self): kfs = [make_kf(0.0, 100, 50), make_kf(1.0, 110, 60), make_kf(2.0, 120, 70)] interp = CropInterpolator(kfs, segment_start=0.0) segments = interp._split_into_segments(kfs) assert len(segments) == 1 assert len(segments[0]) == 3 def test_single_scene_change(self): kfs = [ make_kf(0.0, 100, 50), make_kf(1.0, 110, 60), make_kf(2.0, 800, 600, scene_change=True), make_kf(3.0, 810, 610), ] interp = CropInterpolator([make_kf(0.0, 0, 0), make_kf(1.0, 1, 1)], segment_start=0.0) segments = interp._split_into_segments(kfs) assert len(segments) == 2 assert len(segments[0]) == 2 # t=0, t=1 assert len(segments[1]) == 2 # t=2 (scene_change), t=3 def test_multiple_scene_changes(self): kfs = [ make_kf(0.0, 100, 50), make_kf(2.0, 800, 600, scene_change=True), make_kf(4.0, 300, 400, scene_change=True), ] interp = CropInterpolator([make_kf(0.0, 0, 0), make_kf(1.0, 1, 1)], segment_start=0.0) segments = interp._split_into_segments(kfs) assert len(segments) == 3 def test_scene_change_as_first_keyframe(self): kfs = [ make_kf(0.0, 100, 50, scene_change=True), make_kf(1.0, 110, 60), ] interp = CropInterpolator([make_kf(0.0, 0, 0), make_kf(1.0, 1, 1)], segment_start=0.0) # scene_change on first keyframe doesn't split (no previous segment) segments = interp._split_into_segments(kfs) assert len(segments) == 1 def test_empty_keyframes(self): interp = CropInterpolator([make_kf(0.0, 0, 0), make_kf(1.0, 1, 1)], segment_start=0.0) segments = interp._split_into_segments([]) assert segments == [] class TestCropInterpolatorGetPosition: def test_single_keyframe_constant_position(self): # frame_height=1440 > crop_height=1280, so y=100 is valid kf = make_kf(0.0, 200, 100) interp = CropInterpolator([kf], segment_start=0.0) x, y = interp.get_position(0.0, frame_width=1920, frame_height=1440) assert x == 200 assert y == 100 def test_single_keyframe_position_at_different_times(self): kf = make_kf(0.0, 200, 100) interp = CropInterpolator([kf], segment_start=0.0) for t in [0.0, 1.5, 5.0]: x, y = interp.get_position(t, frame_width=1920, frame_height=1440) assert x == 200 assert y == 100 def test_linear_interpolation_midpoint(self): kfs = [make_kf(0.0, 0, 0), make_kf(2.0, 200, 100)] interp = CropInterpolator(kfs, segment_start=0.0) # At t=1.0, should be halfway between (0,0) and (200,100) x, y = interp.get_position(1.0, frame_width=1920, frame_height=1440) assert x == pytest.approx(100, abs=2) assert y == pytest.approx(50, abs=2) def test_position_clipped_to_frame_bounds(self): # frame_width=800, crop_width=720, so max x = 80 # frame_height=1300, crop_height=1280, so max y = 20 kf = make_kf(0.0, 500, 500) # both x and y are out of bounds interp = CropInterpolator([kf], segment_start=0.0) x, y = interp.get_position(0.0, frame_width=800, frame_height=1300) assert x <= 800 - 720 # max valid x = frame_width - crop_width assert y <= 1300 - 1280 # max valid y = frame_height - crop_height def test_scene_boundary_uses_new_segment(self): """Position at scene boundary should use new scene's interpolator.""" kfs = [ make_kf(0.0, 100, 100), make_kf(2.0, 800, 100, scene_change=True), make_kf(4.0, 810, 100), ] interp = CropInterpolator(kfs, segment_start=0.0) # Before boundary: first segment x_before, _ = interp.get_position(1.0, frame_width=1920, frame_height=1440) # After boundary: second segment (x=800) x_after, _ = interp.get_position(2.0, frame_width=1920, frame_height=1440) # The two positions should come from different scenes (jump, not smooth) assert x_before < 500 # Still in first scene territory assert x_after >= 800 # At or after scene boundary position (clamped) def test_position_at_boundary_zero(self): kf = make_kf(0.0, 0, 0) interp = CropInterpolator([kf], segment_start=0.0) x, y = interp.get_position(0.0, frame_width=1920, frame_height=1440) assert x == 0 assert y == 0