Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <scenedetect -m>`. TIMECODE can be specified in frames (-m 100), in seconds with `s` suffix (-m 3.5s), or timecode (-m 00:01:52.778).
Expand Down
4 changes: 4 additions & 0 deletions scenedetect.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
4 changes: 4 additions & 0 deletions scenedetect/_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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),
}

Expand Down
16 changes: 13 additions & 3 deletions scenedetect/detectors/threshold_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def __init__(
add_final_scene: bool = False,
method: Method = Method.FLOOR,
block_size=None,
min_out_length: TimecodeLike = 0,
):
"""
Arguments:
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
137 changes: 137 additions & 0 deletions tests/test_threshold_detector.py

@Breakthrough Breakthrough Sep 15, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File headers must all use standardized format:

#
#            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 <http://www.bcastell.com>.
# PySceneDetect is licensed under the BSD 3-Clause License; see the
# included LICENSE file, or visit one of the above pages for details.
#

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to the exact header format you provided in 8c1d7df. Also replaced the OpenCV fourcc alias in the test with cv2.VideoWriter.fourcc, which fixes the Pyright failure without changing the generated video.

Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#
# 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 <http://www.bcastell.com>.
# 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

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]