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 8945ebbd..6062b1b1 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", + 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") + ), +) @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: 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": 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 b3eca646..740f38b3 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: bool, ): """Handles the `split-video` command.""" del cuts # split-video only uses scenes. assert context.video_stream is not None + if expand and scenes: + video_duration = context.video_stream.duration + if video_duration is None: + logger.warning("Cannot --expand: 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..8e84ca94 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": 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..44f4a306 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: FrameTimecode, + end: 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