""" Tests for debug frame saving in multi-scene code path. Bug: when PySceneDetect detects >1 scene in a segment, generate_crop_path uses _extract_detections_for_segment → _generate_keyframes_from_detections, neither of which calls _save_debug_frame. The single-scene path (_process_segment_unified) does call _save_debug_frame, so debug frames appear only for videos where no scene changes are detected. Fix: add _save_debug_frames_from_keyframes that re-reads the video once after keyframes are computed, and call it from the multi-scene branch. """ from pathlib import Path from unittest.mock import MagicMock, call, patch import numpy as np import pytest from fansifter_clipper.models import CropKeyframe from fansifter_clipper.reframing import Reframer # ─── helpers ───────────────────────────────────────────────────────────────── FAKE_FRAME = np.zeros((1080, 1920, 3), dtype=np.uint8) def make_kf(time, x=100, y=0, width=607, height=1080, method="mediapipe_face", conf=0.8, scene_change=False): return CropKeyframe(time=time, x=x, y=y, width=width, height=height, detection_method=method, confidence=conf, scene_change=scene_change) # ─── existing _save_debug_frame behaviour ──────────────────────────────────── class TestSaveDebugFrameOutputDir: """Baseline: _save_debug_frame must write to output_dir/debug_frames/.""" def test_saves_to_root_debug_frames(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) reframer = Reframer(debug=True) reframer._save_debug_frame(FAKE_FRAME.copy(), 100, 0, 607, 1080, 1.0) assert (tmp_path / "debug_frames").exists() assert len(list((tmp_path / "debug_frames").iterdir())) == 1 def test_saves_to_output_dir_when_set(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) output_dir = tmp_path / "output" / "run1" output_dir.mkdir(parents=True) reframer = Reframer(debug=True, output_dir=output_dir) reframer._save_debug_frame(FAKE_FRAME.copy(), 100, 0, 607, 1080, 1.5) debug_dir = output_dir / "debug_frames" assert debug_dir.exists(), "debug_frames subdir must be created inside output_dir" assert len(list(debug_dir.iterdir())) == 1 def test_no_output_dir_copy_when_output_dir_is_none(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) reframer = Reframer(debug=True, output_dir=None) reframer._save_debug_frame(FAKE_FRAME.copy(), 100, 0, 607, 1080, 1.0) # Root debug_frames exists; no other debug_frames directory assert (tmp_path / "debug_frames").exists() # ─── reproducing the bug: new method must exist ─────────────────────────────── class TestSaveDebugFramesFromKeyframesExists: """ The multi-scene path needs a dedicated method to save debug frames from pre-computed keyframes (because it has no frame data during keyframe gen). These tests fail before the fix (method doesn't exist) and pass after. """ def test_method_exists_on_reframer(self): """Fail before fix: _save_debug_frames_from_keyframes missing.""" reframer = Reframer() assert hasattr(reframer, "_save_debug_frames_from_keyframes"), ( "Reframer._save_debug_frames_from_keyframes is missing — " "multi-scene debug frames will never be saved." ) # ─── behaviour of the new method ───────────────────────────────────────────── class TestSaveDebugFramesFromKeyframes: """ Verify _save_debug_frames_from_keyframes correctly reads frames and delegates to _save_debug_frame for every keyframe. """ def _make_mock_cap(self, frame=None, succeed=True): cap = MagicMock() cap.read.return_value = (succeed, (frame if frame is not None else FAKE_FRAME.copy())) return cap def test_seeks_and_reads_once_per_keyframe(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) output_dir = tmp_path / "out" output_dir.mkdir() reframer = Reframer(debug=True, output_dir=output_dir) keyframes = [make_kf(1.0), make_kf(2.0), make_kf(3.0)] mock_cap = self._make_mock_cap() with patch("cv2.VideoCapture", return_value=mock_cap): reframer._save_debug_frames_from_keyframes(Path("dummy.mp4"), keyframes) assert mock_cap.set.call_count == len(keyframes), ( "Should seek to each keyframe time exactly once" ) assert mock_cap.read.call_count == len(keyframes) mock_cap.release.assert_called_once() def test_writes_one_file_per_keyframe_in_output_dir(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) output_dir = tmp_path / "run" output_dir.mkdir() reframer = Reframer(debug=True, output_dir=output_dir) keyframes = [make_kf(1.0), make_kf(2.5), make_kf(4.0)] with patch("cv2.VideoCapture", return_value=self._make_mock_cap()): reframer._save_debug_frames_from_keyframes(Path("dummy.mp4"), keyframes) debug_dir = output_dir / "debug_frames" assert debug_dir.exists(), "output_dir/debug_frames must be created" saved = list(debug_dir.iterdir()) assert len(saved) == len(keyframes), ( f"Expected {len(keyframes)} debug frames, found {len(saved)}" ) def test_empty_keyframes_does_not_open_video(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) reframer = Reframer(debug=True, output_dir=tmp_path) with patch("cv2.VideoCapture") as mock_cv: reframer._save_debug_frames_from_keyframes(Path("dummy.mp4"), []) mock_cv.assert_not_called() def test_failed_frame_read_is_skipped_gracefully(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) output_dir = tmp_path / "out" output_dir.mkdir() reframer = Reframer(debug=True, output_dir=output_dir) keyframes = [make_kf(1.0), make_kf(2.0)] mock_cap = MagicMock() # First frame succeeds, second fails mock_cap.read.side_effect = [ (True, FAKE_FRAME.copy()), (False, None), ] with patch("cv2.VideoCapture", return_value=mock_cap): # Must not raise reframer._save_debug_frames_from_keyframes(Path("dummy.mp4"), keyframes) mock_cap.release.assert_called_once() def test_sets_instance_state_from_keyframe(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) reframer = Reframer(debug=True, output_dir=tmp_path) kf = make_kf(1.0, method="haar_face", conf=0.72, scene_change=True) with patch("cv2.VideoCapture", return_value=self._make_mock_cap()): reframer._save_debug_frames_from_keyframes(Path("dummy.mp4"), [kf]) assert reframer._last_detection_method == "haar_face" assert reframer._last_confidence == pytest.approx(0.72) assert reframer._is_scene_change is True def test_seeks_to_correct_millisecond_timestamps(self, tmp_path, monkeypatch): """Verify cv2.CAP_PROP_POS_MSEC is used for seeking, not frame number.""" import cv2 monkeypatch.chdir(tmp_path) reframer = Reframer(debug=True, output_dir=tmp_path) keyframes = [make_kf(2.5), make_kf(5.0)] mock_cap = self._make_mock_cap() with patch("cv2.VideoCapture", return_value=mock_cap): reframer._save_debug_frames_from_keyframes(Path("dummy.mp4"), keyframes) seek_calls = mock_cap.set.call_args_list # Each seek should use CAP_PROP_POS_MSEC and convert seconds → ms for i, (kf, c) in enumerate(zip(keyframes, seek_calls)): prop, value = c[0] assert prop == cv2.CAP_PROP_POS_MSEC, f"seek {i} used wrong property" assert value == pytest.approx(kf.time * 1000, rel=1e-3)