1717
1818import pytest
1919
20- from scenedetect import SceneManager
20+ from scenedetect import SceneManager , open_video
2121from scenedetect .common import FrameTimecode , Timecode
2222from scenedetect .detectors import ContentDetector
2323from scenedetect .stats_manager import StatsManager
2424
25-
26- def _open_pyav (path : str ):
27- """Open a video with the PyAV backend."""
28- from scenedetect .backends .pyav import VideoStreamAv
29-
30- return VideoStreamAv (path )
31-
32-
33- def _open_opencv (path : str ):
34- """Open a video with the OpenCV backend."""
35- from scenedetect .backends .opencv import VideoStreamCv2
36-
37- return VideoStreamCv2 (path )
25+ # Expected scene cuts for `goldeneye-vfr.mp4` detected with ContentDetector() and end_time=10.0s.
26+ # Entries are (start_timecode, end_timecode). All backends should agree on cut timecodes since
27+ # CAP_PROP_POS_MSEC gives accurate PTS-derived timestamps. The last scene ends at the clip
28+ # boundary (end_time) which may vary slightly between backends based on frame counting.
29+ EXPECTED_SCENES_VFR : ty .List [ty .Tuple [str , str ]] = [
30+ ("00:00:00.000" , "00:00:03.921" ),
31+ ("00:00:03.921" , "00:00:09.676" ),
32+ ]
3833
3934
4035class TestVFR :
4136 """Test VFR video handling."""
4237
4338 def test_vfr_position_is_timecode (self , test_vfr_video : str ):
4439 """Position should be a Timecode-backed FrameTimecode."""
45- video = _open_pyav (test_vfr_video )
40+ video = open_video (test_vfr_video , backend = "pyav" )
4641 assert video .read () is not False
4742 assert isinstance (video .position ._time , Timecode )
4843
4944 def test_vfr_position_monotonic_pyav (self , test_vfr_video : str ):
50- """PTS-based position should be monotonically non-decreasing."""
51- video = _open_pyav (test_vfr_video )
45+ """PTS-based position should be monotonically non-decreasing (PyAV) ."""
46+ video = open_video (test_vfr_video , backend = "pyav" )
5247 last_seconds = - 1.0
5348 frame_count = 0
5449 while True :
@@ -64,8 +59,8 @@ def test_vfr_position_monotonic_pyav(self, test_vfr_video: str):
6459 assert frame_count > 0
6560
6661 def test_vfr_position_monotonic_opencv (self , test_vfr_video : str ):
67- """PTS-based position should be monotonically non-decreasing with OpenCV."""
68- video = _open_opencv (test_vfr_video )
62+ """PTS-based position should be monotonically non-decreasing ( OpenCV) ."""
63+ video = open_video (test_vfr_video , backend = "opencv" )
6964 last_seconds = - 1.0
7065 frame_count = 0
7166 while True :
@@ -80,23 +75,36 @@ def test_vfr_position_monotonic_opencv(self, test_vfr_video: str):
8075 frame_count += 1
8176 assert frame_count > 0
8277
83- def test_vfr_scene_detection (self , test_vfr_video : str ):
84- """Scene detection should work on VFR video and produce reasonable timestamps."""
85- video = _open_pyav (test_vfr_video )
78+ @pytest .mark .parametrize ("backend" , ["pyav" , "opencv" ])
79+ def test_vfr_scene_detection (self , test_vfr_video : str , backend : str ):
80+ """Scene detection on VFR video should produce timestamps matching known ground truth.
81+
82+ Both PyAV (native PTS) and OpenCV (CAP_PROP_POS_MSEC) should agree on scene cuts since
83+ both expose accurate PTS-derived timestamps.
84+ """
85+ video = open_video (test_vfr_video , backend = backend )
8686 sm = SceneManager ()
8787 sm .add_detector (ContentDetector ())
88- sm .detect_scenes (video = video )
88+ sm .detect_scenes (video = video , end_time = 10.0 )
8989 scene_list = sm .get_scene_list ()
90- # Should detect at least one scene.
91- assert len (scene_list ) > 0
92- # All timestamps should be non-negative and within video duration.
93- for start , end in scene_list :
94- assert start .seconds >= 0
95- assert end .seconds > start .seconds
90+
91+ # The last scene ends at the clip boundary which may vary by backend; only check known cuts.
92+ assert len (scene_list ) >= len (EXPECTED_SCENES_VFR ), (
93+ f"[{ backend } ] Expected at least { len (EXPECTED_SCENES_VFR )} scenes, got { len (scene_list )} "
94+ )
95+ for i , ((start , end ), (exp_start_tc , exp_end_tc )) in enumerate (
96+ zip (scene_list , EXPECTED_SCENES_VFR , strict = False )
97+ ):
98+ assert start .get_timecode () == exp_start_tc , (
99+ f"[{ backend } ] Scene { i + 1 } start: expected { exp_start_tc !r} , got { start .get_timecode ()!r} "
100+ )
101+ assert end .get_timecode () == exp_end_tc , (
102+ f"[{ backend } ] Scene { i + 1 } end: expected { exp_end_tc !r} , got { end .get_timecode ()!r} "
103+ )
96104
97105 def test_vfr_seek_pyav (self , test_vfr_video : str ):
98106 """Seeking should work with VFR video."""
99- video = _open_pyav (test_vfr_video )
107+ video = open_video (test_vfr_video , backend = "pyav" )
100108 target_time = 2.0 # seconds
101109 video .seek (target_time )
102110 frame = video .read ()
@@ -106,20 +114,18 @@ def test_vfr_seek_pyav(self, test_vfr_video: str):
106114
107115 def test_vfr_stats_manager (self , test_vfr_video : str ):
108116 """StatsManager should work correctly with VFR video."""
109- video = _open_pyav (test_vfr_video )
117+ video = open_video (test_vfr_video , backend = "pyav" )
110118 stats = StatsManager ()
111119 sm = SceneManager (stats_manager = stats )
112120 sm .add_detector (ContentDetector ())
113121 sm .detect_scenes (video = video )
114- # Stats should have metrics for frames.
115- scene_list = sm .get_scene_list ()
116- assert len (scene_list ) > 0
122+ assert len (sm .get_scene_list ()) > 0
117123
118124 def test_vfr_csv_output (self , test_vfr_video : str , tmp_path ):
119125 """CSV export should work correctly with VFR video."""
120126 from scenedetect .output import write_scene_list
121127
122- video = _open_pyav (test_vfr_video )
128+ video = open_video (test_vfr_video , backend = "pyav" )
123129 sm = SceneManager ()
124130 sm .add_detector (ContentDetector ())
125131 sm .detect_scenes (video = video )
@@ -134,18 +140,17 @@ def test_vfr_csv_output(self, test_vfr_video: str, tmp_path):
134140 with open (csv_path , "r" ) as f :
135141 reader = csv .reader (f )
136142 rows = list (reader )
137- # Header + at least one data row.
138143 assert len (rows ) >= 3 # 2 header rows + data
139144
140145 def test_cfr_position_is_timecode (self , test_movie_clip : str ):
141146 """CFR video positions should also be Timecode-backed with PTS support."""
142- video = _open_pyav (test_movie_clip )
147+ video = open_video (test_movie_clip , backend = "pyav" )
143148 assert video .read () is not False
144149 assert isinstance (video .position ._time , Timecode )
145150
146151 def test_cfr_frame_num_exact (self , test_movie_clip : str ):
147152 """For CFR video, frame_num should be exact (not approximate)."""
148- video = _open_pyav (test_movie_clip )
153+ video = open_video (test_movie_clip , backend = "pyav" )
149154 for expected_frame in range (1 , 11 ):
150155 assert video .read () is not False
151156 assert video .position .frame_num == expected_frame - 1
0 commit comments