From b677cd01349b1d45b340c5ca8fec4719c2072d37 Mon Sep 17 00:00:00 2001 From: Charles Vestal Date: Sun, 17 May 2026 09:39:42 +0200 Subject: [PATCH 1/3] Add --expand-to-video flag to split-video (#115) Allows scenes to be detected within a sub-region of a video (via time -s/-e) while having the resulting split clips extend to cover content outside that analysis window. The first output clip extends back to the start of the video, and the last clip extends to the end. Adds a pure helper expand_scenes_to_bounds() to scene_manager.py and wires it into the split-video command behind --expand-to-video. Falls back to the original scene boundaries (with a warning) if the video duration is not available. --- scenedetect/_cli/__init__.py | 11 ++++++++ scenedetect/_cli/commands.py | 13 +++++++++ scenedetect/_cli/config.py | 1 + scenedetect/scene_manager.py | 28 +++++++++++++++++++ tests/test_scene_manager.py | 53 +++++++++++++++++++++++++++++++++++- 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 8945ebbd..e93692df 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1294,6 +1294,15 @@ def list_scenes_command( USER_CONFIG.get_help_string("split-video", "mkvmerge") ), ) +@click.option( + "--expand-to-video", + is_flag=True, + flag_value=True, + default=False, + help="Extend the first/last output clips to cover the full input video, even if `time -s/-e` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split.{}".format( + USER_CONFIG.get_help_string("split-video", "expand-to-video") + ), +) @click.pass_context def split_video_command( ctx: click.Context, @@ -1306,6 +1315,7 @@ def split_video_command( preset: str | None, args: str | None, mkvmerge: bool, + expand_to_video: bool, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1372,6 +1382,7 @@ def split_video_command( "output": ctx.config.get_value("split-video", "output", output), "show_output": not ctx.config.get_value("split-video", "quiet", quiet), "ffmpeg_args": args, + "expand_to_video": ctx.config.get_value("split-video", "expand-to-video", expand_to_video), } ctx.add_command(cli_commands.split_video, split_video_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index b3eca646..7207428d 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -37,6 +37,7 @@ CutList, Interpolation, SceneList, + expand_scenes_to_bounds, ) logger = logging.getLogger("pyscenedetect") @@ -216,11 +217,23 @@ def split_video( output: str, show_output: bool, ffmpeg_args: str, + expand_to_video: bool, ): """Handles the `split-video` command.""" del cuts # split-video only uses scenes. assert context.video_stream is not None + if expand_to_video and scenes: + video_duration = context.video_stream.duration + if video_duration is None: + logger.warning("Cannot expand-to-video: video duration is unavailable for this stream.") + else: + scenes = expand_scenes_to_bounds( + scenes, + start=context.video_stream.base_timecode, + end=video_duration, + ) + if use_mkvmerge: name_format = name_format.removesuffix("-$SCENE_NUMBER") diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 6c593865..62fafb2e 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -468,6 +468,7 @@ class FcpFormat(Enum): "split-video": { "args": _DEFAULT_FFMPEG_ARGS, "copy": False, + "expand-to-video": False, "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, "mkvmerge": False, diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index f68c3962..ac81030a 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -140,6 +140,34 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI return frame_width / float(effective_width) +def expand_scenes_to_bounds( + scenes: SceneList, + start: int | FrameTimecode, + end: int | FrameTimecode, +) -> SceneList: + """Return a new scene list whose first scene starts at `start` and last scene ends at `end`. + + Useful when scenes were detected within a sub-region of a video (e.g. via the `time` + command's `-s`/`-e`) but the caller wants the resulting clip boundaries to cover content + outside that analysis window. + + Arguments: + scenes: List of (start, end) FrameTimecode pairs. + start: Desired start of the first scene. + end: Desired end of the last scene. + + Returns: + A new scene list with the outer endpoints replaced. The input is not modified. + An empty input is returned unchanged. + """ + if not scenes: + return list(scenes) + expanded = list(scenes) + expanded[0] = (start, expanded[0][1]) + expanded[-1] = (expanded[-1][0], end) + return expanded + + def get_scenes_from_cuts( cut_list: CutList, start_pos: int | FrameTimecode, diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 0ba91fc2..b5388e7a 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -20,7 +20,7 @@ from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.common import FrameTimecode from scenedetect.detectors import AdaptiveDetector, ContentDetector -from scenedetect.scene_manager import SceneManager +from scenedetect.scene_manager import SceneManager, expand_scenes_to_bounds TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394] @@ -210,3 +210,54 @@ def test_crop_invalid(): sm.crop = (1, 1, 1) # type: ignore[assignment] with pytest.raises(ValueError): sm.crop = (1, 1, 1, -1) + + +def test_expand_scenes_to_bounds_two_scenes(): + """Scenes detected inside a sub-window should be extended outward.""" + fps = 10.0 + t0 = FrameTimecode(0, fps) + t130 = FrameTimecode(130, fps) + t150 = FrameTimecode(150, fps) + t170 = FrameTimecode(170, fps) + t300 = FrameTimecode(300, fps) + + scenes = [(t130, t150), (t150, t170)] + expanded = expand_scenes_to_bounds(scenes, start=t0, end=t300) + + assert expanded == [(t0, t150), (t150, t300)] + + +def test_expand_scenes_to_bounds_empty(): + """Empty scene lists pass through unchanged.""" + fps = 10.0 + assert expand_scenes_to_bounds([], FrameTimecode(0, fps), FrameTimecode(100, fps)) == [] + + +def test_expand_scenes_to_bounds_single_scene(): + """A single scene gets both endpoints extended.""" + fps = 10.0 + t0 = FrameTimecode(0, fps) + t130 = FrameTimecode(130, fps) + t170 = FrameTimecode(170, fps) + t300 = FrameTimecode(300, fps) + + scenes = [(t130, t170)] + expanded = expand_scenes_to_bounds(scenes, start=t0, end=t300) + + assert expanded == [(t0, t300)] + + +def test_expand_scenes_to_bounds_does_not_mutate_input(): + """The input scene list must not be modified in place.""" + fps = 10.0 + t0 = FrameTimecode(0, fps) + t130 = FrameTimecode(130, fps) + t150 = FrameTimecode(150, fps) + t170 = FrameTimecode(170, fps) + t300 = FrameTimecode(300, fps) + + scenes = [(t130, t150), (t150, t170)] + original = list(scenes) + expand_scenes_to_bounds(scenes, start=t0, end=t300) + + assert scenes == original From 86625cfd535e5de643107744930c798b343f6852 Mon Sep 17 00:00:00 2001 From: Charles Vestal Date: Mon, 18 May 2026 18:48:32 +0200 Subject: [PATCH 2/3] fix(scene_manager): narrow expand_scenes_to_bounds params to FrameTimecode The function builds a SceneList (list[tuple[FrameTimecode, FrameTimecode]]), so accepting int violated the tuple element type at assignment. All callers pass FrameTimecode. Co-Authored-By: Claude Opus 4.7 (1M context) --- scenedetect/scene_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index ac81030a..44f4a306 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -142,8 +142,8 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI def expand_scenes_to_bounds( scenes: SceneList, - start: int | FrameTimecode, - end: int | FrameTimecode, + start: FrameTimecode, + end: FrameTimecode, ) -> SceneList: """Return a new scene list whose first scene starts at `start` and last scene ends at `end`. From 92246666e9c8a4b163d8a2309ed3302aa80fb3dd Mon Sep 17 00:00:00 2001 From: Charles Vestal Date: Tue, 19 May 2026 14:59:19 +0200 Subject: [PATCH 3/3] split-video: rename --expand-to-video to --expand, add cfg/docs Addresses PR #551 review: - Rename CLI flag --expand-to-video to --expand (terser form). - Add `expand` entry to scenedetect.cfg template. - Document --expand option in docs/cli.rst. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cli.rst | 4 ++++ scenedetect.cfg | 5 +++++ scenedetect/_cli/__init__.py | 8 ++++---- scenedetect/_cli/commands.py | 6 +++--- scenedetect/_cli/config.py | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index cca81f67..c252efad 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -857,6 +857,10 @@ Options Split video using mkvmerge. Faster than re-encoding, but less precise. If set, options other than :option:`-f/--filename <-f>`, :option:`-q/--quiet <-q>` and :option:`-o/--output <-o>` will be ignored. Note that mkvmerge automatically appends the $SCENE_NUMBER suffix. +.. option:: --expand + + Extend the first/last output clips to cover the full input video, even if the :ref:`time ` command's ``--start``/``--end`` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split. + .. _command-time: diff --git a/scenedetect.cfg b/scenedetect.cfg index a18189e5..0612c626 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -206,6 +206,11 @@ # Arguments to specify to ffmpeg for encoding. Quotes are not required. #args = -map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac +# Extend the first/last output clips to cover the full input video, even if +# `time -s/-e` limited the analysis window. Useful for keeping content outside +# the analyzed region attached to the adjacent split. +#expand = no + [save-images] # Folder to output videos. Overrides [global] output option. diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index e93692df..6062b1b1 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1295,12 +1295,12 @@ def list_scenes_command( ), ) @click.option( - "--expand-to-video", + "--expand", is_flag=True, flag_value=True, default=False, help="Extend the first/last output clips to cover the full input video, even if `time -s/-e` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split.{}".format( - USER_CONFIG.get_help_string("split-video", "expand-to-video") + USER_CONFIG.get_help_string("split-video", "expand") ), ) @click.pass_context @@ -1315,7 +1315,7 @@ def split_video_command( preset: str | None, args: str | None, mkvmerge: bool, - expand_to_video: bool, + expand: bool, ): ctx = ctx.obj assert isinstance(ctx, CliContext) @@ -1382,7 +1382,7 @@ def split_video_command( "output": ctx.config.get_value("split-video", "output", output), "show_output": not ctx.config.get_value("split-video", "quiet", quiet), "ffmpeg_args": args, - "expand_to_video": ctx.config.get_value("split-video", "expand-to-video", expand_to_video), + "expand": ctx.config.get_value("split-video", "expand", expand), } ctx.add_command(cli_commands.split_video, split_video_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 7207428d..740f38b3 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -217,16 +217,16 @@ def split_video( output: str, show_output: bool, ffmpeg_args: str, - expand_to_video: bool, + expand: bool, ): """Handles the `split-video` command.""" del cuts # split-video only uses scenes. assert context.video_stream is not None - if expand_to_video and scenes: + if expand and scenes: video_duration = context.video_stream.duration if video_duration is None: - logger.warning("Cannot expand-to-video: video duration is unavailable for this stream.") + logger.warning("Cannot --expand: video duration is unavailable for this stream.") else: scenes = expand_scenes_to_bounds( scenes, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 62fafb2e..8e84ca94 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -468,7 +468,7 @@ class FcpFormat(Enum): "split-video": { "args": _DEFAULT_FFMPEG_ARGS, "copy": False, - "expand-to-video": False, + "expand": False, "filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER", "high-quality": False, "mkvmerge": False,