Skip to content

Commit 86469a2

Browse files
Add --expand-to-video flag to split-video (#115) (#551)
* 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. * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 73c40d1 commit 86469a2

7 files changed

Lines changed: 114 additions & 1 deletion

File tree

docs/cli.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,10 @@ Options
857857

858858
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.
859859

860+
.. option:: --expand
861+
862+
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.
863+
860864

861865
.. _command-time:
862866

scenedetect.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@
206206
# Arguments to specify to ffmpeg for encoding. Quotes are not required.
207207
#args = -map 0:v:0 -map 0:a? -map 0:s? -c:v libx264 -preset veryfast -crf 22 -c:a aac
208208

209+
# Extend the first/last output clips to cover the full input video, even if
210+
# `time -s/-e` limited the analysis window. Useful for keeping content outside
211+
# the analyzed region attached to the adjacent split.
212+
#expand = no
213+
209214

210215
[save-images]
211216
# Folder to output videos. Overrides [global] output option.

scenedetect/_cli/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,15 @@ def list_scenes_command(
12941294
USER_CONFIG.get_help_string("split-video", "mkvmerge")
12951295
),
12961296
)
1297+
@click.option(
1298+
"--expand",
1299+
is_flag=True,
1300+
flag_value=True,
1301+
default=False,
1302+
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(
1303+
USER_CONFIG.get_help_string("split-video", "expand")
1304+
),
1305+
)
12971306
@click.pass_context
12981307
def split_video_command(
12991308
ctx: click.Context,
@@ -1306,6 +1315,7 @@ def split_video_command(
13061315
preset: str | None,
13071316
args: str | None,
13081317
mkvmerge: bool,
1318+
expand: bool,
13091319
):
13101320
ctx = ctx.obj
13111321
assert isinstance(ctx, CliContext)
@@ -1372,6 +1382,7 @@ def split_video_command(
13721382
"output": ctx.config.get_value("split-video", "output", output),
13731383
"show_output": not ctx.config.get_value("split-video", "quiet", quiet),
13741384
"ffmpeg_args": args,
1385+
"expand": ctx.config.get_value("split-video", "expand", expand),
13751386
}
13761387
ctx.add_command(cli_commands.split_video, split_video_args)
13771388

scenedetect/_cli/commands.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
CutList,
3838
Interpolation,
3939
SceneList,
40+
expand_scenes_to_bounds,
4041
)
4142

4243
logger = logging.getLogger("pyscenedetect")
@@ -216,11 +217,23 @@ def split_video(
216217
output: str,
217218
show_output: bool,
218219
ffmpeg_args: str,
220+
expand: bool,
219221
):
220222
"""Handles the `split-video` command."""
221223
del cuts # split-video only uses scenes.
222224
assert context.video_stream is not None
223225

226+
if expand and scenes:
227+
video_duration = context.video_stream.duration
228+
if video_duration is None:
229+
logger.warning("Cannot --expand: video duration is unavailable for this stream.")
230+
else:
231+
scenes = expand_scenes_to_bounds(
232+
scenes,
233+
start=context.video_stream.base_timecode,
234+
end=video_duration,
235+
)
236+
224237
if use_mkvmerge:
225238
name_format = name_format.removesuffix("-$SCENE_NUMBER")
226239

scenedetect/_cli/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ class FcpFormat(Enum):
468468
"split-video": {
469469
"args": _DEFAULT_FFMPEG_ARGS,
470470
"copy": False,
471+
"expand": False,
471472
"filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER",
472473
"high-quality": False,
473474
"mkvmerge": False,

scenedetect/scene_manager.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,34 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI
140140
return frame_width / float(effective_width)
141141

142142

143+
def expand_scenes_to_bounds(
144+
scenes: SceneList,
145+
start: FrameTimecode,
146+
end: FrameTimecode,
147+
) -> SceneList:
148+
"""Return a new scene list whose first scene starts at `start` and last scene ends at `end`.
149+
150+
Useful when scenes were detected within a sub-region of a video (e.g. via the `time`
151+
command's `-s`/`-e`) but the caller wants the resulting clip boundaries to cover content
152+
outside that analysis window.
153+
154+
Arguments:
155+
scenes: List of (start, end) FrameTimecode pairs.
156+
start: Desired start of the first scene.
157+
end: Desired end of the last scene.
158+
159+
Returns:
160+
A new scene list with the outer endpoints replaced. The input is not modified.
161+
An empty input is returned unchanged.
162+
"""
163+
if not scenes:
164+
return list(scenes)
165+
expanded = list(scenes)
166+
expanded[0] = (start, expanded[0][1])
167+
expanded[-1] = (expanded[-1][0], end)
168+
return expanded
169+
170+
143171
def get_scenes_from_cuts(
144172
cut_list: CutList,
145173
start_pos: int | FrameTimecode,

tests/test_scene_manager.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from scenedetect.backends.opencv import VideoStreamCv2
2121
from scenedetect.common import FrameTimecode
2222
from scenedetect.detectors import AdaptiveDetector, ContentDetector
23-
from scenedetect.scene_manager import SceneManager
23+
from scenedetect.scene_manager import SceneManager, expand_scenes_to_bounds
2424

2525
TEST_VIDEO_START_FRAMES_ACTUAL = [150, 180, 394]
2626

@@ -210,3 +210,54 @@ def test_crop_invalid():
210210
sm.crop = (1, 1, 1) # type: ignore[assignment]
211211
with pytest.raises(ValueError):
212212
sm.crop = (1, 1, 1, -1)
213+
214+
215+
def test_expand_scenes_to_bounds_two_scenes():
216+
"""Scenes detected inside a sub-window should be extended outward."""
217+
fps = 10.0
218+
t0 = FrameTimecode(0, fps)
219+
t130 = FrameTimecode(130, fps)
220+
t150 = FrameTimecode(150, fps)
221+
t170 = FrameTimecode(170, fps)
222+
t300 = FrameTimecode(300, fps)
223+
224+
scenes = [(t130, t150), (t150, t170)]
225+
expanded = expand_scenes_to_bounds(scenes, start=t0, end=t300)
226+
227+
assert expanded == [(t0, t150), (t150, t300)]
228+
229+
230+
def test_expand_scenes_to_bounds_empty():
231+
"""Empty scene lists pass through unchanged."""
232+
fps = 10.0
233+
assert expand_scenes_to_bounds([], FrameTimecode(0, fps), FrameTimecode(100, fps)) == []
234+
235+
236+
def test_expand_scenes_to_bounds_single_scene():
237+
"""A single scene gets both endpoints extended."""
238+
fps = 10.0
239+
t0 = FrameTimecode(0, fps)
240+
t130 = FrameTimecode(130, fps)
241+
t170 = FrameTimecode(170, fps)
242+
t300 = FrameTimecode(300, fps)
243+
244+
scenes = [(t130, t170)]
245+
expanded = expand_scenes_to_bounds(scenes, start=t0, end=t300)
246+
247+
assert expanded == [(t0, t300)]
248+
249+
250+
def test_expand_scenes_to_bounds_does_not_mutate_input():
251+
"""The input scene list must not be modified in place."""
252+
fps = 10.0
253+
t0 = FrameTimecode(0, fps)
254+
t130 = FrameTimecode(130, fps)
255+
t150 = FrameTimecode(150, fps)
256+
t170 = FrameTimecode(170, fps)
257+
t300 = FrameTimecode(300, fps)
258+
259+
scenes = [(t130, t150), (t150, t170)]
260+
original = list(scenes)
261+
expand_scenes_to_bounds(scenes, start=t0, end=t300)
262+
263+
assert scenes == original

0 commit comments

Comments
 (0)