Skip to content
Open
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
28 changes: 28 additions & 0 deletions docs/api/migration_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ The following have been removed from the ``SceneDetector`` interface:
- ``stats_manager_required`` property - no longer needed
- ``SparseSceneDetector`` interface - removed entirely

Temporal Defaults
-----------------------------------------------------------------------

All built-in detector constructors now default ``min_scene_len`` to ``"0.6s"`` (temporal) instead of ``15`` (frames). This makes detection behavior consistent across different framerates and is required for correct VFR support. Existing code passing an explicit ``int`` still works:

.. code:: python

# v0.6 - default was 15 frames
detector = ContentDetector()

# v0.7 - default is "0.6s" (~15 frames at 25fps, ~14 at 24fps, ~18 at 30fps)
detector = ContentDetector()

# To preserve exact v0.6 behavior:
detector = ContentDetector(min_scene_len=15)

The ``save_images()`` function parameter ``frame_margin`` has been renamed to ``margin`` and now defaults to ``"0.1s"`` instead of ``1`` (frame). The old keyword ``frame_margin=`` still works with a deprecation warning:

.. code:: python

# v0.6
save_images(scene_list, video, frame_margin=1)

# v0.7
save_images(scene_list, video, margin="0.1s")


=======================================================================
``FrameTimecode`` Changes
Expand Down Expand Up @@ -204,3 +230,5 @@ CLI Changes
- The ``-d``/``--min-delta-hsv`` option on ``detect-adaptive`` has been removed. Use ``-c``/``--min-content-val`` instead.
- VFR videos now work correctly with both the OpenCV and PyAV backends.
- New ``save-xml`` command for exporting scenes in Final Cut Pro XML format.
- ``save-images``: ``--frame-margin`` renamed to ``--margin``, now accepts temporal values (e.g. ``0.1s``). Default changed from 1 frame to ``0.1s``. Old name still works with a deprecation warning.
- Config file: ``[save-images]`` option ``frame-margin`` renamed to ``margin``. Old name still accepted with a deprecation warning.
2 changes: 2 additions & 0 deletions docs/api/output.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
Ouptut
-------------------------------------------------

.. autodata:: scenedetect.output.DEFAULT_MARGIN

.. autofunction:: scenedetect.output.save_images

.. autofunction:: scenedetect.output.is_ffmpeg_available
Expand Down
6 changes: 3 additions & 3 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -658,11 +658,11 @@ Options

Default: ``3``

.. option:: -m N, --frame-margin N
.. option:: -m DURATION, --margin DURATION

Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.
Margin from scene boundary for first/last image. Accepts duration (``0.1s``), frame count (``3``), or ``HH:MM:SS.mmm`` format.

Default: ``3``
Default: ``0.1s``

.. option:: -s S, --scale S

Expand Down
5 changes: 3 additions & 2 deletions scenedetect.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@
# Compression amount for png images (0 to 9). Only affects size, not quality.
#compression = 3

# Number of frames to ignore around each scene cut when selecting frames.
#frame-margin = 1
# Margin from scene boundary for first/last image. Accepts time (0.1s),
# frames (3), or timecode (00:00:00.100).
#margin = 0.1s

# Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double).
#scale = 1.0
Expand Down
3 changes: 2 additions & 1 deletion scenedetect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from scenedetect.video_stream import VideoStream, VideoOpenFailure
from scenedetect.output import (
save_images,
DEFAULT_MARGIN,
split_video_ffmpeg,
split_video_mkvmerge,
is_ffmpeg_available,
Expand All @@ -53,7 +54,7 @@
VideoMetadata,
SceneMetadata,
)
from scenedetect.detector import SceneDetector
from scenedetect.detector import DEFAULT_MIN_SCENE_LEN, SceneDetector
from scenedetect.detectors import (
ContentDetector,
AdaptiveDetector,
Expand Down
23 changes: 17 additions & 6 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,12 +1396,18 @@ def split_video_command(
)
@click.option(
"-m",
"--margin",
metavar="DURATION",
default=None,
type=click.STRING,
help="Margin from scene boundary for first/last image. Accepts duration (0.1s), frame count (3), or HH:MM:SS.mmm format.%s"
% (USER_CONFIG.get_help_string("save-images", "margin")),
)
@click.option(
"--frame-margin",
metavar="N",
default=None,
type=click.INT,
help="Number of frames to ignore at beginning/end of scenes when saving images. Controls temporal padding on scene boundaries.%s"
% (USER_CONFIG.get_help_string("save-images", "num-images")),
type=click.STRING,
hidden=True,
)
@click.option(
"--scale",
Expand Down Expand Up @@ -1441,7 +1447,8 @@ def save_images_command(
quality: ty.Optional[int] = None,
png: bool = False,
compression: ty.Optional[int] = None,
frame_margin: ty.Optional[int] = None,
margin: ty.Optional[str] = None,
frame_margin: ty.Optional[str] = None,
scale: ty.Optional[float] = None,
height: ty.Optional[int] = None,
width: ty.Optional[int] = None,
Expand Down Expand Up @@ -1487,9 +1494,13 @@ def save_images_command(
raise click.BadParameter("\n".join(error_strs), param_hint="save-images")
output = ctx.config.get_value("save-images", "output", output)

if frame_margin is not None and margin is None:
logger.warning("--frame-margin is deprecated, use --margin instead.")
margin = frame_margin

save_images_args = {
"encoder_param": compression if png else quality,
"frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin),
"margin": ctx.config.get_value("save-images", "margin", margin),
"height": height,
"image_extension": image_extension,
"filename": ctx.config.get_value("save-images", "filename", filename),
Expand Down
4 changes: 2 additions & 2 deletions scenedetect/_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def save_images(
scenes: SceneList,
cuts: CutList,
num_images: int,
frame_margin: int,
margin: ty.Union[int, float, str],
image_extension: str,
encoder_param: int,
filename: str,
Expand All @@ -199,7 +199,7 @@ def save_images(
scene_list=scenes,
video=context.video_stream,
num_images=num_images,
frame_margin=frame_margin,
margin=margin,
image_extension=image_extension,
encoder_param=encoder_param,
image_name_template=filename,
Expand Down
26 changes: 24 additions & 2 deletions scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ class XmlFormat(Enum):
"compression": RangeValue(3, min_val=0, max_val=9),
"filename": "$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER",
"format": "jpeg",
"frame-margin": 1,
"margin": TimecodeValue("0.1s"),
"height": 0,
"num-images": 3,
"output": None,
Expand Down Expand Up @@ -504,6 +504,12 @@ class XmlFormat(Enum):
DEPRECATED_COMMANDS: ty.Dict[str, str] = {"export-html": "save-html"}
"""Deprecated config file sections that have a 1:1 mapping to a new replacement."""

DEPRECATED_OPTIONS: ty.Dict[ty.Tuple[str, str], str] = {
("save-images", "frame-margin"): "margin",
}
"""Deprecated config file options that have a 1:1 mapping to a new replacement.
Keys are (section, old_option) tuples, values are the new option name."""


def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessage]]:
"""Validates the layout of the section/option mapping. Returns a bool indicating if validation
Expand Down Expand Up @@ -538,7 +544,16 @@ def _validate_structure(parser: ConfigParser) -> ty.Tuple[bool, ty.List[LogMessa
logs.append((logging.ERROR, f"Unsupported config section: [{section_name}]"))
continue
for option_name, _ in parser.items(section_name):
if option_name not in CONFIG_MAP[section].keys():
if (section, option_name) in DEPRECATED_OPTIONS:
new_option = DEPRECATED_OPTIONS[(section, option_name)]
logs.append(
(
logging.WARNING,
f"[{section_name}] option `{option_name}` is deprecated,"
f" use `{new_option}` instead.",
)
)
elif option_name not in CONFIG_MAP[section].keys():
success = False
logs.append(
(
Expand All @@ -564,6 +579,13 @@ def _parse_config(parser: ConfigParser) -> ty.Tuple[ty.Optional[ConfigDict], ty.
replacement = DEPRECATED_COMMANDS[deprecated_command]
parser[replacement] = parser[deprecated_command]
del parser[deprecated_command]
# Re-map deprecated options to their replacements. Only remap when the new option is not
# already explicitly set (the explicit value should take precedence).
for (section, old_option), new_option in DEPRECATED_OPTIONS.items():
if section in parser and old_option in parser[section]:
if new_option not in parser[section]:
parser[section][new_option] = parser[section][old_option]
parser.remove_option(section, old_option)
for command in CONFIG_MAP:
config[command] = {}
for option in CONFIG_MAP[command]:
Expand Down
25 changes: 10 additions & 15 deletions scenedetect/_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def get_detect_content_params(
self,
threshold: ty.Optional[float] = None,
luma_only: bool = None,
min_scene_len: ty.Optional[str] = None,
min_scene_len: ty.Optional[ty.Union[int, float, str]] = None,
weights: ty.Optional[ty.Tuple[float, float, float, float]] = None,
kernel_size: ty.Optional[int] = None,
filter_mode: ty.Optional[str] = None,
Expand All @@ -325,10 +325,9 @@ def get_detect_content_params(
else:
if min_scene_len is None:
if self.config.is_default("detect-content", "min-scene-len"):
min_scene_len = self.min_scene_len.frame_num
min_scene_len = self.min_scene_len.seconds
else:
min_scene_len = self.config.get_value("detect-content", "min-scene-len")
min_scene_len = self.parse_timecode(min_scene_len).frame_num

if weights is not None:
try:
Expand All @@ -354,7 +353,7 @@ def get_detect_adaptive_params(
min_content_val: ty.Optional[float] = None,
frame_window: ty.Optional[int] = None,
luma_only: bool = None,
min_scene_len: ty.Optional[str] = None,
min_scene_len: ty.Optional[ty.Union[int, float, str]] = None,
weights: ty.Optional[ty.Tuple[float, float, float, float]] = None,
kernel_size: ty.Optional[int] = None,
) -> ty.Dict[str, ty.Any]:
Expand All @@ -365,10 +364,9 @@ def get_detect_adaptive_params(
else:
if min_scene_len is None:
if self.config.is_default("detect-adaptive", "min-scene-len"):
min_scene_len = self.min_scene_len.frame_num
min_scene_len = self.min_scene_len.seconds
else:
min_scene_len = self.config.get_value("detect-adaptive", "min-scene-len")
min_scene_len = self.parse_timecode(min_scene_len).frame_num

if weights is not None:
try:
Expand All @@ -395,7 +393,7 @@ def get_detect_threshold_params(
threshold: ty.Optional[float] = None,
fade_bias: ty.Optional[float] = None,
add_last_scene: bool = None,
min_scene_len: ty.Optional[str] = None,
min_scene_len: ty.Optional[ty.Union[int, float, str]] = None,
) -> ty.Dict[str, ty.Any]:
"""Handle detect-threshold command options and return args to construct one with."""

Expand All @@ -404,10 +402,9 @@ def get_detect_threshold_params(
else:
if min_scene_len is None:
if self.config.is_default("detect-threshold", "min-scene-len"):
min_scene_len = self.min_scene_len.frame_num
min_scene_len = self.min_scene_len.seconds
else:
min_scene_len = self.config.get_value("detect-threshold", "min-scene-len")
min_scene_len = self.parse_timecode(min_scene_len).frame_num
# TODO(v1.0): add_last_scene cannot be disabled right now.
return {
"add_final_scene": add_last_scene
Expand All @@ -421,7 +418,7 @@ def get_detect_hist_params(
self,
threshold: ty.Optional[float] = None,
bins: ty.Optional[int] = None,
min_scene_len: ty.Optional[str] = None,
min_scene_len: ty.Optional[ty.Union[int, float, str]] = None,
) -> ty.Dict[str, ty.Any]:
"""Handle detect-hist command options and return args to construct one with."""

Expand All @@ -430,10 +427,9 @@ def get_detect_hist_params(
else:
if min_scene_len is None:
if self.config.is_default("detect-hist", "min-scene-len"):
min_scene_len = self.min_scene_len.frame_num
min_scene_len = self.min_scene_len.seconds
else:
min_scene_len = self.config.get_value("detect-hist", "min-scene-len")
min_scene_len = self.parse_timecode(min_scene_len).frame_num
return {
"bins": self.config.get_value("detect-hist", "bins", bins),
"min_scene_len": min_scene_len,
Expand All @@ -445,7 +441,7 @@ def get_detect_hash_params(
threshold: ty.Optional[float] = None,
size: ty.Optional[int] = None,
lowpass: ty.Optional[int] = None,
min_scene_len: ty.Optional[str] = None,
min_scene_len: ty.Optional[ty.Union[int, float, str]] = None,
) -> ty.Dict[str, ty.Any]:
"""Handle detect-hash command options and return args to construct one with."""

Expand All @@ -454,10 +450,9 @@ def get_detect_hash_params(
else:
if min_scene_len is None:
if self.config.is_default("detect-hash", "min-scene-len"):
min_scene_len = self.min_scene_len.frame_num
min_scene_len = self.min_scene_len.seconds
else:
min_scene_len = self.config.get_value("detect-hash", "min-scene-len")
min_scene_len = self.parse_timecode(min_scene_len).frame_num
return {
"lowpass": self.config.get_value("detect-hash", "lowpass", lowpass),
"min_scene_len": min_scene_len,
Expand Down
Loading
Loading