From e7b16297d8486ea2317557c39f61c35f26b21078 Mon Sep 17 00:00:00 2001 From: Mingyang Wu Date: Mon, 14 Sep 2026 21:38:22 +0800 Subject: [PATCH 1/2] Add a minimum fade-out duration to ThresholdDetector Assisted-by: OpenAI Codex --- docs/cli.rst | 6 + scenedetect.cfg | 4 + scenedetect/_cli/__init__.py | 11 ++ scenedetect/_cli/config.py | 1 + scenedetect/_cli/context.py | 4 + scenedetect/detectors/threshold_detector.py | 16 ++- tests/test_threshold_detector.py | 128 ++++++++++++++++++++ 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 tests/test_threshold_detector.py diff --git a/docs/cli.rst b/docs/cli.rst index 20ceed77..4f1e4bcf 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -416,6 +416,12 @@ Options Default: ``True`` +.. option:: --min-out-length TIMECODE + + Minimum time spent faded out before a cut is allowed. Ignores shorter fades even when the minimum scene length is met. Also applies to a final fade-out, including the last processed frame. Zero allows fades of any duration. TIMECODE can be specified in frames (4), seconds with an `s` suffix (0.4s), or timecode (00:00:00.400). + + Default: ``0`` + .. option:: -m TIMECODE, --min-scene-len TIMECODE Minimum length of any scene. Overrides global option :option:`-m/--min-scene-len `. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778). diff --git a/scenedetect.cfg b/scenedetect.cfg index 945362af..540ab839 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -168,6 +168,10 @@ # Discard colour information and only use luminance (yes/no). #luma-only = no +# Minimum time faded out before a cut is allowed, including a final fade-out. +# Accepts frames, seconds (e.g. 0.4s), or timecode (e.g. 00:00:00.400). +#min-out-length = 0 + # Minimum length of a given scene (overrides [global] option). #min-scene-len = 0.6s diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index f75929be..0b3d6d96 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -763,6 +763,15 @@ def detect_adaptive_command( USER_CONFIG.get_help_string("detect-threshold", "add-last-scene") ), ) +@click.option( + "--min-out-length", + metavar="TIMECODE", + type=click.STRING, + default=None, + help="Minimum time spent faded out before a cut is allowed. Ignores shorter fades even when the minimum scene length is met. Also applies to a final fade-out, including the last processed frame. Zero allows fades of any duration. TIMECODE can be specified in frames (4), seconds with an `s` suffix (0.4s), or timecode (00:00:00.400).{}".format( + USER_CONFIG.get_help_string("detect-threshold", "min-out-length") + ), +) @click.option( "--min-scene-len", "-m", @@ -783,6 +792,7 @@ def detect_threshold_command( fade_bias: float | None, add_last_scene: bool, min_scene_len: str | None, + min_out_length: str | None, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -791,6 +801,7 @@ def detect_threshold_command( fade_bias=fade_bias, add_last_scene=add_last_scene, min_scene_len=min_scene_len, + min_out_length=min_out_length, ) ctx.add_detector(ThresholdDetector, detector_args) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index dc7684fe..f47bd5a9 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -390,6 +390,7 @@ class FcpFormat(Enum): "detect-threshold": { "add-last-scene": True, "fade-bias": RangeValue(0, min_val=-100.0, max_val=100.0), + "min-out-length": TimecodeValue(0), "min-scene-len": TimecodeValue(0), "threshold": RangeValue(12.0, min_val=0.0, max_val=255.0), }, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index e5cebb0f..6cb8cc4b 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -405,6 +405,7 @@ def get_detect_threshold_params( fade_bias: float | None = None, add_last_scene: bool | None = None, min_scene_len: str | None = None, + min_out_length: str | None = None, ) -> dict[str, ty.Any]: """Handle detect-threshold command options and return args to construct one with.""" @@ -415,6 +416,9 @@ def get_detect_threshold_params( or self.config.get_value("detect-threshold", "add-last-scene"), "fade_bias": self.config.get_value("detect-threshold", "fade-bias", fade_bias), "min_scene_len": min_scene_len_frames, + "min_out_length": self.parse_timecode( + self.config.get_value("detect-threshold", "min-out-length", min_out_length) + ), "threshold": self.config.get_value("detect-threshold", "threshold", threshold), } diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index 945bc987..9b6a067c 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -53,6 +53,7 @@ def __init__( add_final_scene: bool = False, method: Method = Method.FLOOR, block_size=None, + min_out_length: TimecodeLike = 0, ): """ Arguments: @@ -69,6 +70,9 @@ def __init__( generate an additional scene at this timecode. method: How to treat `threshold` when detecting fade events. block_size: [DEPRECATED] DO NOT USE. For backwards compatibility. + min_out_length: Minimum time spent faded out before a cut can be added. Accepts the + same formats as min_scene_len. Shorter fades are ignored. Defaults to 0, + allowing fades of any duration. """ if block_size is not None: warnings.warn( @@ -82,6 +86,7 @@ def __init__( self.method = ThresholdDetector.Method(method) self.fade_bias = fade_bias self.min_scene_len = min_scene_len + self.min_out_length = min_out_length self.processed_frame = False self.last_scene_cut: FrameTimecode | None = None # Whether to add an additional scene or not when ending on a fade out @@ -141,8 +146,10 @@ def process_frame( (self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or (self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold) ): - # Only add the scene if min_scene_len frames have passed. - if (timecode - self.last_scene_cut) >= self.min_scene_len: + # Both the scene and the fade-out must meet their minimum durations. + if (timecode - self.last_scene_cut) >= self.min_scene_len and ( + timecode - self.last_fade["frame"] + ) >= self.min_out_length: # Just faded into a new scene, compute timecode for the scene # split based on the fade bias. Use frame-number arithmetic so the # result is identical across backends - float seconds + framerate @@ -173,7 +180,8 @@ def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: Only writes the scene cut if add_final_scene is true, and the last fade that was detected was a fade-out. There is no bias applied to this cut (since there is no corresponding fade-in) so it will be located at the - exact frame where the fade-out crossed the detection threshold. + exact frame where the fade-out crossed the detection threshold. The fade-out + must also meet min_out_length, including the last processed frame. """ # If the last fade detected was a fade out, we add a corresponding new @@ -186,6 +194,8 @@ def post_process(self, timecode: FrameTimecode) -> list[FrameTimecode]: and self.add_final_scene and self.last_fade["frame"] is not None and elapsed >= self.min_scene_len + # timecode is the last processed frame, so include it in the fade duration. + and (timecode + 1 - self.last_fade["frame"]) >= self.min_out_length ): cuts.append(self.last_fade["frame"]) return cuts diff --git a/tests/test_threshold_detector.py b/tests/test_threshold_detector.py new file mode 100644 index 00000000..640894d4 --- /dev/null +++ b/tests/test_threshold_detector.py @@ -0,0 +1,128 @@ +# Copyright (C) 2026 Mingyang Wu. +# PySceneDetect is licensed under the BSD 3-Clause License; see LICENSE for details. +"""Duration filtering for threshold-based fades.""" + +import csv + +import cv2 +import numpy as np +import pytest + +from scenedetect import FrameTimecode, SceneManager, open_video +from scenedetect.detectors import ThresholdDetector +from tests.helpers import invoke_cli + + +def _detect_levels(levels, **kwargs): + detector = ThresholdDetector(**kwargs) + cuts = [] + for index, level in enumerate(levels): + timecode = FrameTimecode(index, fps=10.0) + cuts.extend(detector.process_frame(timecode, np.full((4, 4, 3), level, np.uint8))) + cuts.extend(detector.post_process(timecode)) + return [cut.frame_num for cut in cuts] + + +@pytest.mark.parametrize("method", list(ThresholdDetector.Method)) +@pytest.mark.parametrize("fade_bias, expected", [(-1.0, 22), (0.0, 25), (1.0, 28)]) +def test_short_fade_does_not_affect_next_cut(method, fade_bias, expected): + levels = [128] * 10 + [0] * 2 + [128] * 10 + [0] * 6 + [128] * 10 + if method == ThresholdDetector.Method.CEILING: + levels = [255 - level for level in levels] + assert _detect_levels( + levels, + threshold=128, + method=method, + min_scene_len=3, + min_out_length=4, + fade_bias=fade_bias, + ) == [expected] + + +@pytest.mark.parametrize("out_length, expected", [(3, []), (4, [12]), (5, [12])]) +@pytest.mark.parametrize("minimum", [4, 0.4, "0.4s", "00:00:00.400", FrameTimecode(4, 10.0)]) +def test_min_out_length_boundary(out_length, expected, minimum): + assert ( + _detect_levels( + [128] * 10 + [0] * out_length + [128] * 10, + min_scene_len=0, + min_out_length=minimum, + ) + == expected + ) + + +def test_min_scene_len_still_applies(): + levels = [128] * 10 + [0] * 4 + [128] * 3 + [0] * 4 + [128] * 10 + assert _detect_levels(levels, min_scene_len=10, min_out_length=4) == [12] + assert _detect_levels(levels, min_scene_len=0, min_out_length=4) == [12, 19] + + +@pytest.mark.parametrize("add_final_scene", [False, True]) +@pytest.mark.parametrize("out_length", [3, 4]) +def test_final_fade_counts_last_frame(out_length, add_final_scene): + assert _detect_levels( + [128] * 10 + [0] * out_length, + min_scene_len=0, + min_out_length=4, + add_final_scene=add_final_scene, + ) == ([10] if add_final_scene and out_length == 4 else []) + + +def test_default_keeps_short_fades(): + levels = [128] * 10 + [0] * 2 + [128] * 10 + [0] * 3 + assert _detect_levels(levels, min_scene_len=0, add_final_scene=True) == [11, 22] + assert _detect_levels(levels, min_scene_len=0, add_final_scene=True, min_out_length=0) == [ + 11, + 22, + ] + + +def _write_video(path, levels): + writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"MJPG"), 10, (64, 48)) + assert writer.isOpened() + try: + for level in levels: + writer.write(np.full((48, 64, 3), level, np.uint8)) + finally: + writer.release() + return str(path) + + +@pytest.mark.parametrize("backend", ["opencv", "pyav"]) +@pytest.mark.parametrize("final_out_length", [3, 4]) +def test_min_out_length_decoded_video(tmp_path, auto_close, backend, final_out_length): + if backend == "pyav": + pytest.importorskip("av") + levels = [128] * 10 + [0] * 2 + [128] * 10 + [0] * 6 + [128] * 10 + path = _write_video(tmp_path / "fades.avi", levels + [0] * final_out_length) + video = auto_close(open_video(path, backend=backend)) + manager = SceneManager() + manager.add_detector( + ThresholdDetector(min_scene_len=0, min_out_length="0.4s", add_final_scene=True) + ) + assert manager.detect_scenes(video) == len(levels) + final_out_length + scenes = manager.get_scene_list() + assert [start.frame_num for start, _ in scenes] == ( + [0, 25, 38] if final_out_length == 4 else [0, 25] + ) + assert scenes[-1][1].frame_num == len(levels) + final_out_length + + +@pytest.mark.parametrize("minimum", ["4", "0.4s", "00:00:00.400"]) +@pytest.mark.parametrize("use_config", [False, True]) +def test_min_out_length_cli(tmp_path, minimum, use_config): + levels = [128] * 10 + [0] * 2 + [128] * 10 + [0] * 6 + [128] * 10 + path = _write_video(tmp_path / "fades.avi", levels) + args = ["-i", path, "-o", str(tmp_path), "-m", "0"] + if use_config: + config = tmp_path / "scenedetect.cfg" + config.write_text(f"[detect-threshold]\nmin-out-length = {minimum}\n") + args += ["-c", str(config), "detect-threshold"] + else: + args += ["detect-threshold", "--min-out-length", minimum] + exit_code, output = invoke_cli([*args, "list-scenes", "-f", "scenes", "--skip-cuts"]) + assert exit_code == 0, output + with (tmp_path / "scenes.csv").open(newline="") as scene_file: + scenes = list(csv.DictReader(scene_file)) + assert [int(scene["Start Frame"]) - 1 for scene in scenes] == [0, 25] From 8c1d7df381cc8da33918f04eaffbe74ff0b05e0c Mon Sep 17 00:00:00 2001 From: Mingyang Wu Date: Tue, 15 Sep 2026 10:39:31 +0800 Subject: [PATCH 2/2] Use standard test header and typed OpenCV fourcc API Signed-off-by: Mingyang Wu Assisted-by: OpenAI Codex --- tests/test_threshold_detector.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_threshold_detector.py b/tests/test_threshold_detector.py index 640894d4..3a107977 100644 --- a/tests/test_threshold_detector.py +++ b/tests/test_threshold_detector.py @@ -1,5 +1,14 @@ -# Copyright (C) 2026 Mingyang Wu. -# PySceneDetect is licensed under the BSD 3-Clause License; see LICENSE for details. +# +# PySceneDetect: Python-Based Video Scene Detector +# ------------------------------------------------------------------- +# [ Site: https://scenedetect.com ] +# [ Docs: https://scenedetect.com/docs/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# """Duration filtering for threshold-based fades.""" import csv @@ -79,7 +88,7 @@ def test_default_keeps_short_fades(): def _write_video(path, levels): - writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"MJPG"), 10, (64, 48)) + writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"MJPG"), 10, (64, 48)) assert writer.isOpened() try: for level in levels: