Skip to content

Commit 82a895f

Browse files
committed
[tests] Expand unit and release tests for 0.7 release
1 parent 67de2f1 commit 82a895f

4 files changed

Lines changed: 231 additions & 2 deletions

File tree

tests/release/test_backends.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import pytest
2121

22-
from scenedetect import ContentDetector, SceneManager, open_video
22+
from scenedetect import ContentDetector, SceneManager, ThresholdDetector, open_video
2323

2424
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
2525

@@ -88,3 +88,41 @@ def test_cross_backend_consistency(rel_path, is_vfr):
8888
assert actual == expected, (
8989
f"CFR frame-number mismatch between {backend} and {reference}"
9090
)
91+
92+
93+
@pytest.mark.release
94+
def test_cross_backend_threshold_determinism():
95+
"""detect-threshold cut frames must be backend-deterministic across PyAV/OpenCV/MoviePy.
96+
97+
Regression coverage for the changelog item: previously the cut could differ by 1 frame
98+
between PyAV and OpenCV when the fade midpoint landed on a `.5` rounding boundary
99+
(PyAV uses sub-microsecond PTS; OpenCV uses millisecond-truncated CAP_PROP_POS_MSEC).
100+
"""
101+
video_path = os.path.join(REPO_ROOT, "tests/resources/fades.mp4")
102+
if not os.path.exists(video_path):
103+
pytest.skip("tests/resources/fades.mp4 not present.")
104+
105+
backends = _installed_backends()
106+
if len(backends) < 2:
107+
pytest.skip(f"Need at least two backends, have: {backends}")
108+
109+
results = {}
110+
for backend in backends:
111+
try:
112+
video = open_video(video_path, backend=backend)
113+
except Exception as exc:
114+
pytest.skip(f"{backend} failed to open fades.mp4: {exc}")
115+
sm = SceneManager()
116+
sm.add_detector(ThresholdDetector())
117+
sm.detect_scenes(video)
118+
# `frame_num` of the first frame of each cut, excluding the implicit 0th cut.
119+
results[backend] = [s[0].frame_num for s in sm.get_scene_list()[1:]]
120+
121+
reference = backends[0]
122+
expected = results[reference]
123+
for backend in backends[1:]:
124+
actual = results[backend]
125+
assert actual == expected, (
126+
f"detect-threshold cut frames differ between {backend}={actual} and "
127+
f"{reference}={expected} - the .5-boundary rounding fix has regressed."
128+
)

tests/release/test_cli_permutations.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,125 @@ def test_cli_min_scene_len_smoke(test_video_file, tmp_path):
140140
cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."),
141141
)
142142
assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}"
143+
144+
145+
@pytest.mark.release
146+
def test_cli_save_fcp_smoke(test_video_file, tmp_path):
147+
"""save-fcp writes a well-formed Final Cut Pro XML."""
148+
import xml.etree.ElementTree as ET
149+
150+
result = _run(
151+
[
152+
"-i",
153+
os.path.abspath(test_video_file),
154+
"-o",
155+
str(tmp_path),
156+
"detect-content",
157+
"save-fcp",
158+
],
159+
cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."),
160+
)
161+
assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}"
162+
xml_files = [p for p in tmp_path.iterdir() if p.suffix == ".xml"]
163+
assert xml_files, "save-fcp produced no .xml file"
164+
# Parse must succeed; root <fcpxml> or <xmeml> depending on the FCP variant.
165+
root = ET.parse(xml_files[0]).getroot()
166+
assert root.tag in ("fcpxml", "xmeml"), f"Unexpected root element: {root.tag}"
167+
168+
169+
@pytest.mark.release
170+
def test_cli_save_qp_smoke(test_video_file, tmp_path):
171+
"""save-qp writes a QP file with `<frame> I <shift>` lines for scene boundaries."""
172+
result = _run(
173+
[
174+
"-i",
175+
os.path.abspath(test_video_file),
176+
"-o",
177+
str(tmp_path),
178+
"detect-content",
179+
"save-qp",
180+
],
181+
cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."),
182+
)
183+
assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}"
184+
qp_files = [p for p in tmp_path.iterdir() if p.suffix == ".qp"]
185+
assert qp_files, "save-qp produced no .qp file"
186+
contents = qp_files[0].read_text().strip()
187+
assert contents, "save-qp produced an empty file"
188+
# Each line must be `<frame_number> I <shift>` where shift is an integer.
189+
for line in contents.splitlines():
190+
parts = line.split()
191+
assert len(parts) == 3 and parts[0].isdigit() and parts[1] == "I", (
192+
f"Malformed QP line: {line!r}"
193+
)
194+
int(parts[2]) # shift must parse as int (may be negative)
195+
196+
197+
@pytest.mark.release
198+
def test_cli_save_html_smoke(test_video_file, tmp_path):
199+
"""save-html replaces the deprecated export-html and produces an HTML report.
200+
201+
Note: save-html lacks its own --output option and ignores the global -o, so the
202+
file is routed via --filename with an absolute path.
203+
"""
204+
out_html = tmp_path / "scenes.html"
205+
result = _run(
206+
[
207+
"-i",
208+
os.path.abspath(test_video_file),
209+
"detect-content",
210+
"save-html",
211+
"--filename",
212+
str(out_html),
213+
"--no-images",
214+
],
215+
cwd=os.path.abspath(os.path.dirname(test_video_file) + "/../.."),
216+
)
217+
assert result.returncode == 0, f"stderr:\n{result.stderr}\nstdout:\n{result.stdout}"
218+
assert out_html.exists(), f"save-html produced no file at {out_html}"
219+
contents = out_html.read_text(encoding="utf-8")
220+
# The output is an HTML fragment (a <table> of scenes), not a full document.
221+
lowered = contents.lower()
222+
assert "<table" in lowered and "</table>" in lowered, (
223+
f"save-html output is missing the scenes <table>:\n{contents[:500]}"
224+
)
225+
226+
227+
@pytest.mark.release
228+
def test_cli_save_edl_start_timecode_smoke(test_video_file, tmp_path):
229+
"""save-edl --start-timecode produces an EDL where event timestamps are offset by the
230+
requested start. Both SMPTE (HH:MM:SS:FF) and 8-digit (HHMMSSFF) inputs must be accepted."""
231+
repo_cwd = os.path.abspath(os.path.dirname(test_video_file) + "/../..")
232+
233+
def _edl(form: str, out_dir):
234+
result = _run(
235+
[
236+
"-i",
237+
os.path.abspath(test_video_file),
238+
"-o",
239+
str(out_dir),
240+
"detect-content",
241+
"save-edl",
242+
"--start-timecode",
243+
form,
244+
],
245+
cwd=repo_cwd,
246+
)
247+
assert result.returncode == 0, (
248+
f"start-timecode {form!r} failed:\nstderr:\n{result.stderr}\nstdout:\n{result.stdout}"
249+
)
250+
edls = [p for p in out_dir.iterdir() if p.suffix == ".edl"]
251+
assert edls, f"save-edl --start-timecode {form!r} produced no .edl file"
252+
return edls[0].read_text()
253+
254+
# SMPTE form.
255+
smpte_dir = tmp_path / "smpte"
256+
smpte_dir.mkdir()
257+
smpte_text = _edl("01:00:00:00", smpte_dir)
258+
# 8-digit form (semantically equivalent to 01:00:00:00).
259+
digit_dir = tmp_path / "digit"
260+
digit_dir.mkdir()
261+
digit_text = _edl("01000000", digit_dir)
262+
# Both EDLs must contain at least one event line with the 01:00:... offset visible.
263+
assert "01:00:" in smpte_text, f"SMPTE start TC not propagated to EDL:\n{smpte_text}"
264+
assert "01:00:" in digit_text, f"8-digit start TC not propagated to EDL:\n{digit_text}"

tests/test_cli.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,47 @@ def test_cli_framerate_legacy_alias():
367367
assert exit_code == 0
368368

369369

370+
def test_cli_min_scene_len_accepts_all_timecode_forms(tmp_path: Path):
371+
"""`--min-scene-len` (and equivalent options) must accept frames, seconds, and timecodes
372+
in v0.7 per the changelog. The four forms below all resolve to ~20 frames at 23.976 fps
373+
and must produce byte-identical scene lists."""
374+
# 20 frames @ 23.976 fps = 0.8341... s, which rounds to the same nearest frame regardless
375+
# of which form is parsed.
376+
forms = ["20", "0.834", "0.834s", "00:00:00.834"]
377+
outputs = []
378+
for form in forms:
379+
out = tmp_path / f"scenes_{form.replace(':', '_')}.csv"
380+
exit_code, _ = invoke_cli(
381+
[
382+
"-i",
383+
DEFAULT_VIDEO_PATH,
384+
"-o",
385+
str(tmp_path),
386+
"time",
387+
"-s",
388+
"2s",
389+
"-d",
390+
"4s",
391+
"detect-content",
392+
"--min-scene-len",
393+
form,
394+
"list-scenes",
395+
"-f",
396+
out.name,
397+
"-q", # suppress stdout printing
398+
],
399+
)
400+
assert exit_code == 0, f"--min-scene-len {form!r} rejected"
401+
assert out.exists(), f"--min-scene-len {form!r} did not produce {out}"
402+
outputs.append((form, out.read_text()))
403+
# All forms must produce the same scene list.
404+
base_form, base_csv = outputs[0]
405+
for form, csv in outputs[1:]:
406+
assert csv == base_csv, (
407+
f"Scene list differs between --min-scene-len {base_form!r} and {form!r}"
408+
)
409+
410+
370411
def test_cli_list_scenes(tmp_path: Path):
371412
"""Test `list-scenes` command."""
372413
exit_code, _ = invoke_cli(
@@ -624,10 +665,29 @@ def test_cli_save_html(tmp_path: Path):
624665
invoke_scenedetect(base_command, COMMAND="save-html --no-images", output_dir=tmp_path) == 0
625666
)
626667
# Ensure we can still call the now deprecated export-html command.
627-
assert invoke_scenedetect(base_command, COMMAND="save-html", output_dir=tmp_path) == 0
668+
assert invoke_scenedetect(base_command, COMMAND="export-html", output_dir=tmp_path) == 0
628669
# TODO: Check for existence of HTML & image files.
629670

630671

672+
def test_cli_legacy_v06_config_file(tmp_path: Path):
673+
"""A v0.6-era scenedetect.cfg using the deprecated `[export-html]` section must still load
674+
in v0.7. The parser maps `[export-html]` -> `[save-html]` (via DEPRECATED_COMMANDS in
675+
scenedetect/_cli/config.py) and emits a deprecation warning on load. This is the most
676+
likely silent break for users upgrading config files; the option set under both sections
677+
is identical."""
678+
legacy_cfg = tmp_path / "scenedetect.cfg"
679+
legacy_cfg.write_text(
680+
# Mix of unchanged sections and the renamed `[export-html]` section.
681+
"[global]\nmin-scene-len = 0.6s\n\n"
682+
"[detect-content]\nthreshold = 27\n\n"
683+
"[export-html]\nfilename = $VIDEO_NAME-Scenes.html\nno-images = yes\n"
684+
)
685+
exit_code, output = invoke_cli(
686+
["-c", str(legacy_cfg), "-i", DEFAULT_VIDEO_PATH, "time", "-s", "2s", "-d", "1s"],
687+
)
688+
assert exit_code == 0, f"v0.6-style config rejected:\n{output}"
689+
690+
631691
def test_cli_save_qp(tmp_path: Path):
632692
"""Test `save-qp` command with and without a custom filename format."""
633693
EXPECTED_QP_CONTENTS = """

tests/test_timecode.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ def test_frame_rate_for_vfr():
9898
assert tc.time_base != 1 / tc.frame_rate
9999

100100

101+
def test_frame_num_and_frame_rate_are_read_only():
102+
"""Per migration guide, `frame_num`, `frame_rate`, and the legacy `framerate` alias are
103+
read-only properties; callers must construct a new FrameTimecode to change them."""
104+
tc = FrameTimecode(timecode=0, fps=30.0)
105+
for attr in ("frame_num", "frame_rate", "framerate"):
106+
with pytest.raises(AttributeError):
107+
setattr(tc, attr, 99)
108+
109+
101110
def test_equal_frame_rate_legacy_alias():
102111
"""`equal_framerate()` is the soft-deprecated alias for `equal_frame_rate()` (issue #548).
103112
Both forms should produce identical results for every accepted operand type."""

0 commit comments

Comments
 (0)