Skip to content

Commit 82f96f7

Browse files
committed
[build] Deflake VideoStream tests
1 parent a1ca4bc commit 82f96f7

5 files changed

Lines changed: 151 additions & 70 deletions

File tree

‎tests/conftest.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,41 @@ def delayed_start_video() -> str:
161161
return check_exists("tests/resources/delayed_start.mp4")
162162

163163

164+
@pytest.fixture
165+
def auto_close():
166+
"""Registers VideoStreams (or anything closeable) for deterministic cleanup at test end.
167+
168+
Usage: ``video = auto_close(open_video(path))``. Returns its argument unchanged.
169+
Closing test-owned streams while the interpreter is healthy avoids ResourceWarnings
170+
(unclosed PyAV containers / file handles) finalizing during interpreter shutdown,
171+
where native teardown can crash the process exit code (windows-latest CI flake).
172+
"""
173+
from tests.helpers import close_video_stream
174+
175+
streams = []
176+
177+
def _register(stream):
178+
streams.append(stream)
179+
return stream
180+
181+
yield _register
182+
for stream in streams:
183+
close_video_stream(stream)
184+
185+
164186
def pytest_unconfigure(config):
165187
"""Diagnostic for a windows-latest CI flake (silent exit 1 after a green run):
166188
report any non-main threads still alive at session end. Leaked threads keep
167189
VideoStreams alive into interpreter shutdown, where native teardown can crash
168190
the process exit code. tqdm's global monitor singleton is expected and ignored."""
191+
import gc
169192
import sys
170193
import threading
171194

195+
# Finalize any lingering test-owned objects (e.g. av containers kept alive by reference
196+
# cycles) while the interpreter is still healthy, instead of at interpreter shutdown.
197+
gc.collect()
198+
172199
leftover = [
173200
t
174201
for t in threading.enumerate()

‎tests/helpers.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,45 @@
1111
#
1212
"""Shared test helpers."""
1313

14+
import contextlib
15+
import typing as ty
16+
1417
from click.testing import CliRunner
1518

1619
from scenedetect._cli import scenedetect as _scenedetect_cli
1720
from scenedetect._cli.context import CliContext
1821
from scenedetect._cli.controller import run_scenedetect
1922

2023

24+
def close_video_stream(stream: ty.Any) -> None:
25+
"""Deterministically release a VideoStream's native resources.
26+
27+
`VideoStream` has no public close()/context-manager API, so tests release the
28+
backend-specific handles directly. Closing while the interpreter is healthy avoids
29+
ResourceWarnings (and native teardown work) at interpreter shutdown. Safe to call
30+
multiple times; never raises.
31+
"""
32+
backend = getattr(stream, "BACKEND_NAME", None)
33+
if backend == "pyav":
34+
# Close the decode generator first to break its cycle with the container. `_io` is
35+
# the file handle backing the container (opened by the stream when given a path).
36+
for attr in ("_decoder", "_container", "_io"):
37+
handle = getattr(stream, attr, None)
38+
if handle is not None:
39+
with contextlib.suppress(Exception):
40+
handle.close()
41+
elif backend == "opencv":
42+
cap = getattr(stream, "_cap", None)
43+
if cap is not None:
44+
with contextlib.suppress(Exception):
45+
cap.release()
46+
elif backend == "moviepy":
47+
reader = getattr(stream, "_reader", None)
48+
if reader is not None:
49+
with contextlib.suppress(Exception):
50+
reader.close()
51+
52+
2153
def invoke_cli(args: list[str], catch_exceptions: bool = False) -> tuple[int, str]:
2254
"""Invoke the scenedetect CLI in-process using Click's CliRunner.
2355
@@ -30,7 +62,15 @@ def invoke_cli(args: list[str], catch_exceptions: bool = False) -> tuple[int, st
3062
"""
3163
context = CliContext()
3264
runner = CliRunner()
33-
result = runner.invoke(_scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions)
34-
if result.exit_code == 0:
35-
run_scenedetect(context)
36-
return result.exit_code, result.output
65+
try:
66+
result = runner.invoke(
67+
_scenedetect_cli, args, obj=context, catch_exceptions=catch_exceptions
68+
)
69+
if result.exit_code == 0:
70+
run_scenedetect(context)
71+
return result.exit_code, result.output
72+
finally:
73+
# The CLI opens a VideoStream on `context` and has no teardown path; close it here so
74+
# its native handles are released deterministically instead of at interpreter shutdown.
75+
if context.video_stream is not None:
76+
close_video_stream(context.video_stream)

‎tests/test_backend_pyav.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@
2222
from scenedetect.backends.pyav import MAX_CONSECUTIVE_DECODE_FAILURES, VideoStreamAv
2323

2424

25-
def test_video_stream_pyav_bytesio(test_video_file: str):
25+
def test_video_stream_pyav_bytesio(test_video_file: str, auto_close):
2626
"""Test that VideoStreamAv works with a BytesIO input in addition to a path."""
2727
# Mode must be binary!
2828
with open(test_video_file, mode="rb") as video_file:
29-
stream = VideoStreamAv(path_or_io=video_file, threading_mode=None)
29+
stream = auto_close(VideoStreamAv(path_or_io=video_file, threading_mode=None))
3030
assert stream.is_seekable
3131
stream.seek(50)
3232
for _ in range(10):
@@ -56,9 +56,9 @@ def __getattr__(self, name):
5656
return getattr(self._container, name)
5757

5858

59-
def test_read_tolerates_corrupt_frame(test_video_file: str):
59+
def test_read_tolerates_corrupt_frame(test_video_file: str, auto_close):
6060
"""A decode error partway through the stream must be skipped, not stop decoding."""
61-
stream = VideoStreamAv(test_video_file)
61+
stream = auto_close(VideoStreamAv(test_video_file))
6262
injected = False
6363

6464
def fault_injecting_decode(container, *args, **kwargs):
@@ -76,9 +76,9 @@ def fault_injecting_decode(container, *args, **kwargs):
7676
assert stream.decode_failures == 1
7777

7878

79-
def test_read_gives_up_after_consecutive_failures(test_video_file: str, caplog):
79+
def test_read_gives_up_after_consecutive_failures(test_video_file: str, caplog, auto_close):
8080
"""After too many consecutive decode failures, read() must return False, not hang."""
81-
stream = VideoStreamAv(test_video_file)
81+
stream = auto_close(VideoStreamAv(test_video_file))
8282

8383
def always_failing_decode(container, *args, **kwargs):
8484
raise _make_invalid_data_error()

‎tests/test_vfr.py‎

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,16 @@ def _tc_to_secs(tc: str) -> float:
5252
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
5353

5454

55-
def test_vfr_position_is_timecode(test_vfr_video: str):
55+
def test_vfr_position_is_timecode(test_vfr_video: str, auto_close):
5656
"""Position should be a Timecode-backed FrameTimecode."""
57-
video = open_video(test_vfr_video, backend="pyav")
57+
video = auto_close(open_video(test_vfr_video, backend="pyav"))
5858
assert video.read() is not False
5959
assert isinstance(video.position._time, Timecode)
6060

6161

62-
def test_vfr_position_monotonic_pyav(test_vfr_video: str):
62+
def test_vfr_position_monotonic_pyav(test_vfr_video: str, auto_close):
6363
"""PTS-based position should be monotonically non-decreasing (PyAV)."""
64-
video = open_video(test_vfr_video, backend="pyav")
64+
video = auto_close(open_video(test_vfr_video, backend="pyav"))
6565
last_seconds = -1.0
6666
frame_count = 0
6767
while True:
@@ -77,9 +77,9 @@ def test_vfr_position_monotonic_pyav(test_vfr_video: str):
7777
assert frame_count > 0
7878

7979

80-
def test_vfr_position_monotonic_opencv(test_vfr_video: str):
80+
def test_vfr_position_monotonic_opencv(test_vfr_video: str, auto_close):
8181
"""PTS-based position should be monotonically non-decreasing (OpenCV)."""
82-
video = open_video(test_vfr_video, backend="opencv")
82+
video = auto_close(open_video(test_vfr_video, backend="opencv"))
8383
last_seconds = -1.0
8484
frame_count = 0
8585
while True:
@@ -96,13 +96,13 @@ def test_vfr_position_monotonic_opencv(test_vfr_video: str):
9696

9797

9898
@pytest.mark.parametrize("backend", ["pyav", "opencv"])
99-
def test_vfr_scene_detection(test_vfr_video: str, backend: str):
99+
def test_vfr_scene_detection(test_vfr_video: str, backend: str, auto_close):
100100
"""Scene detection on VFR video should produce timestamps matching known ground truth.
101101
102102
Both PyAV (native PTS) and OpenCV (CAP_PROP_POS_MSEC) should agree on scene cuts since
103103
both expose accurate PTS-derived timestamps.
104104
"""
105-
video = open_video(test_vfr_video, backend=backend)
105+
video = auto_close(open_video(test_vfr_video, backend=backend))
106106
sm = SceneManager()
107107
sm.add_detector(ContentDetector())
108108
sm.detect_scenes(video=video, end_time=10.0)
@@ -123,9 +123,9 @@ def test_vfr_scene_detection(test_vfr_video: str, backend: str):
123123
)
124124

125125

126-
def test_vfr_seek_pyav(test_vfr_video: str):
126+
def test_vfr_seek_pyav(test_vfr_video: str, auto_close):
127127
"""Seeking should work with VFR video."""
128-
video = open_video(test_vfr_video, backend="pyav")
128+
video = auto_close(open_video(test_vfr_video, backend="pyav"))
129129
target_time = 2.0 # seconds
130130
video.seek(target_time)
131131
frame = video.read()
@@ -134,21 +134,21 @@ def test_vfr_seek_pyav(test_vfr_video: str):
134134
assert abs(video.position.seconds - target_time) < 1.0
135135

136136

137-
def test_vfr_stats_manager(test_vfr_video: str):
137+
def test_vfr_stats_manager(test_vfr_video: str, auto_close):
138138
"""StatsManager should work correctly with VFR video."""
139-
video = open_video(test_vfr_video, backend="pyav")
139+
video = auto_close(open_video(test_vfr_video, backend="pyav"))
140140
stats = StatsManager()
141141
sm = SceneManager(stats_manager=stats)
142142
sm.add_detector(ContentDetector())
143143
sm.detect_scenes(video=video)
144144
assert len(sm.get_scene_list()) > 0
145145

146146

147-
def test_vfr_csv_output(test_vfr_video: str, tmp_path):
147+
def test_vfr_csv_output(test_vfr_video: str, tmp_path, auto_close):
148148
"""CSV export should work correctly with VFR video."""
149149
from scenedetect.output import write_scene_list
150150

151-
video = open_video(test_vfr_video, backend="pyav")
151+
video = auto_close(open_video(test_vfr_video, backend="pyav"))
152152
sm = SceneManager()
153153
sm.add_detector(ContentDetector())
154154
sm.detect_scenes(video=video)
@@ -167,10 +167,10 @@ def test_vfr_csv_output(test_vfr_video: str, tmp_path):
167167

168168

169169
@pytest.mark.parametrize("backend", ["pyav", "opencv"])
170-
def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str):
170+
def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str, auto_close):
171171
"""Synthetic VFR video (drop every 3rd frame, alternating 1x/2x durations) should produce
172172
timecodes matching known ground truth with both backends."""
173-
video = open_video(test_vfr_drop3_video, backend=backend)
173+
video = auto_close(open_video(test_vfr_drop3_video, backend=backend))
174174
sm = SceneManager()
175175
sm.add_detector(ContentDetector())
176176
sm.detect_scenes(video=video, show_progress=False)
@@ -191,9 +191,9 @@ def test_vfr_drop3_scene_detection(test_vfr_drop3_video: str, backend: str):
191191

192192

193193
@pytest.mark.parametrize("backend", ["pyav", "opencv"])
194-
def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str):
194+
def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str, auto_close):
195195
"""PTS-based position should be monotonically non-decreasing on synthetic VFR video."""
196-
video = open_video(test_vfr_drop3_video, backend=backend)
196+
video = auto_close(open_video(test_vfr_drop3_video, backend=backend))
197197
last_seconds = -1.0
198198
frame_count = 0
199199
while True:
@@ -208,22 +208,22 @@ def test_vfr_drop3_position_monotonic(test_vfr_drop3_video: str, backend: str):
208208
assert frame_count == 160 # 2/3 of original 240 frames in 10s at 24000/1001
209209

210210

211-
def test_cfr_position_is_timecode(test_movie_clip: str):
211+
def test_cfr_position_is_timecode(test_movie_clip: str, auto_close):
212212
"""CFR video positions should also be Timecode-backed with PTS support."""
213-
video = open_video(test_movie_clip, backend="pyav")
213+
video = auto_close(open_video(test_movie_clip, backend="pyav"))
214214
assert video.read() is not False
215215
assert isinstance(video.position._time, Timecode)
216216

217217

218-
def test_cfr_frame_num_exact(test_movie_clip: str):
218+
def test_cfr_frame_num_exact(test_movie_clip: str, auto_close):
219219
"""For CFR video, frame_num should be exact (not approximate)."""
220-
video = open_video(test_movie_clip, backend="pyav")
220+
video = auto_close(open_video(test_movie_clip, backend="pyav"))
221221
for expected_frame in range(1, 11):
222222
assert video.read() is not False
223223
assert video.position.frame_num == expected_frame - 1
224224

225225

226-
def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path):
226+
def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path, auto_close):
227227
"""OpenCV save-images thumbnails should match PyAV thumbnails for all scenes.
228228
229229
If the OpenCV seek off-by-one bug is present, scene thumbnails will show content from the
@@ -233,7 +233,7 @@ def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path):
233233
# must not run per-backend: the cut at 00:01:39.474 scores content_val=27.08 against the
234234
# default threshold of 27.0, so decoder/colorspace differences between backends (or FFmpeg
235235
# builds - e.g. av 17.1.0 on macOS arm64) can flip it, changing the scene count.
236-
video = open_video(test_vfr_video, backend="pyav")
236+
video = auto_close(open_video(test_vfr_video, backend="pyav"))
237237
sm = SceneManager()
238238
sm.add_detector(ContentDetector())
239239
sm.detect_scenes(video=video)
@@ -246,7 +246,7 @@ def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path):
246246
for backend in ("pyav", "opencv"):
247247
out_dir = tmp_path / backend
248248
out_dir.mkdir()
249-
video = open_video(test_vfr_video, backend=backend)
249+
video = auto_close(open_video(test_vfr_video, backend=backend))
250250
rebased = [
251251
(FrameTimecode(start, fps=video.frame_rate), FrameTimecode(end, fps=video.frame_rate))
252252
for start, end in scene_list
@@ -282,9 +282,9 @@ def test_vfr_save_images_opencv_matches_pyav(test_vfr_video: str, tmp_path):
282282

283283

284284
@pytest.mark.parametrize("backend", ["pyav", "opencv"])
285-
def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path):
285+
def test_vfr_csv_accuracy(test_vfr_video: str, backend: str, tmp_path, auto_close):
286286
"""CSV timecodes for VFR video should match known ground truth for both backends."""
287-
video = open_video(test_vfr_video, backend=backend)
287+
video = auto_close(open_video(test_vfr_video, backend=backend))
288288
sm = SceneManager()
289289
sm.add_detector(ContentDetector())
290290
sm.detect_scenes(video=video, end_time=10.0)
@@ -417,15 +417,15 @@ def test_vfr_fcp_export(test_vfr_video: str, fcp_format: str, tmp_path):
417417
assert root.tag == ("fcpxml" if fcp_format == "fcpx" else "xmeml")
418418

419419

420-
def test_vfr_csv_backend_conformance(test_vfr_video: str):
420+
def test_vfr_csv_backend_conformance(test_vfr_video: str, auto_close):
421421
"""PyAV and OpenCV should produce identical scene timecodes for VFR video.
422422
423423
Only the known interior scenes are compared; the last scene's end time may vary slightly
424424
between backends since it reflects the clip boundary rather than a detected cut.
425425
"""
426426
timecodes: dict[str, list[tuple[str, str]]] = {}
427427
for backend in ("pyav", "opencv"):
428-
video = open_video(test_vfr_video, backend=backend)
428+
video = auto_close(open_video(test_vfr_video, backend=backend))
429429
sm = SceneManager()
430430
sm.add_detector(ContentDetector())
431431
sm.detect_scenes(video=video, end_time=10.0)

0 commit comments

Comments
 (0)