Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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-time>` command's ``--start``/``--end`` limited the analysis window. Useful for keeping content outside the analyzed region attached to the adjacent split.


.. _command-time:

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

Expand Down
13 changes: 13 additions & 0 deletions scenedetect/_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
CutList,
Interpolation,
SceneList,
expand_scenes_to_bounds,
)

logger = logging.getLogger("pyscenedetect")
Expand Down Expand Up @@ -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")

Expand Down
1 change: 1 addition & 0 deletions scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions scenedetect/scene_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 52 additions & 1 deletion tests/test_scene_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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
Loading