Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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-to-video",
Comment thread
Breakthrough marked this conversation as resolved.
Outdated
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,
Expand All @@ -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)
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_to_video": ctx.config.get_value("split-video", "expand-to-video", expand_to_video),
}
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_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")

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-to-video": False,
Comment thread
Breakthrough marked this conversation as resolved.
Outdated
"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