Skip to content
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
6 changes: 6 additions & 0 deletions docs/cli/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ It is mostly reliable and fast, although can occasionally run into issues proces

The OpenCV backend also supports image sequences as inputs (e.g. ``frame%02d.jpg`` if you want to load frame001.jpg, frame002.jpg, frame003.jpg...). Make sure to specify the framerate manually (``-f``/``--framerate``) to ensure accurate timing calculations.

Variable framerate (VFR) video is supported. Scene detection uses PTS-derived timestamps from ``CAP_PROP_POS_MSEC`` for accurate timecodes. Seeking compensates for OpenCV's average-fps-based internal seek approximation, so output timecodes remain accurate across the full video.


=======================================================================
PyAV
=======================================================================

The `PyAV <https://github.com/PyAV-Org/PyAV>`_ backend (`av package <https://pypi.org/project/av/>`_) is a more robust backend that handles multiple audio tracks and frame decode errors gracefully.

Variable framerate (VFR) video is fully supported. PyAV uses native PTS timestamps directly from the container, giving the most accurate timecodes for VFR content.

This backend can be used by specifying ``-b pyav`` via command line, or setting ``backend = pyav`` under the ``[global]`` section of your :ref:`config file <scenedetect_cli-config_file>`.


Expand All @@ -41,4 +45,6 @@ MoviePy launches ffmpeg as a subprocess, and can be used with various types of i

The MoviePy backend is still under development and is not included with current Windows distribution. To enable MoviePy support, you must install PySceneDetect using `python` and `pip`.

Variable framerate (VFR) video is **not supported**. MoviePy assumes a fixed framerate, so timecodes for VFR content will be inaccurate. Use the PyAV or OpenCV backend instead.

This backend can be used by specifying ``-b moviepy`` via command line, or setting ``backend = moviepy`` under the ``[global]`` section of your :ref:`config file <scenedetect_cli-config_file>`.
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
37 changes: 21 additions & 16 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 Expand Up @@ -401,17 +401,19 @@ def _save_xml_fcp(
sequence = ElementTree.SubElement(project, "sequence")
ElementTree.SubElement(sequence, "name").text = context.video_stream.name

fps = float(context.video_stream.frame_rate)
ntsc = "True" if context.video_stream.frame_rate.denominator != 1 else "False"
duration = scenes[-1][1] - scenes[0][0]
ElementTree.SubElement(sequence, "duration").text = f"{duration.frame_num}"
ElementTree.SubElement(sequence, "duration").text = str(round(duration.seconds * fps))

rate = ElementTree.SubElement(sequence, "rate")
ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate)
ElementTree.SubElement(rate, "ntsc").text = "False"
ElementTree.SubElement(rate, "timebase").text = str(round(fps))
ElementTree.SubElement(rate, "ntsc").text = ntsc

timecode = ElementTree.SubElement(sequence, "timecode")
tc_rate = ElementTree.SubElement(timecode, "rate")
ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate)
ElementTree.SubElement(tc_rate, "ntsc").text = "False"
ElementTree.SubElement(tc_rate, "timebase").text = str(round(fps))
ElementTree.SubElement(tc_rate, "ntsc").text = ntsc
ElementTree.SubElement(timecode, "frame").text = "0"
ElementTree.SubElement(timecode, "displayformat").text = "NDF"

Expand All @@ -427,13 +429,13 @@ def _save_xml_fcp(
ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}"
ElementTree.SubElement(clip, "enabled").text = "TRUE"
ElementTree.SubElement(clip, "rate").append(
ElementTree.fromstring(f"<timebase>{context.video_stream.frame_rate}</timebase>")
ElementTree.fromstring(f"<timebase>{round(fps)}</timebase>")
)
# TODO: Are these supposed to be frame numbers or another format?
ElementTree.SubElement(clip, "start").text = str(start.frame_num)
ElementTree.SubElement(clip, "end").text = str(end.frame_num)
ElementTree.SubElement(clip, "in").text = str(start.frame_num)
ElementTree.SubElement(clip, "out").text = str(end.frame_num)
# Frame numbers relative to the declared <timebase> fps, computed from PTS seconds.
ElementTree.SubElement(clip, "start").text = str(round(start.seconds * fps))
ElementTree.SubElement(clip, "end").text = str(round(end.seconds * fps))
ElementTree.SubElement(clip, "in").text = str(round(start.seconds * fps))
ElementTree.SubElement(clip, "out").text = str(round(end.seconds * fps))

file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}")
ElementTree.SubElement(file_ref, "name").text = context.video_stream.name
Expand Down Expand Up @@ -485,6 +487,9 @@ def save_xml(
logger.error(f"Unknown format: {format}")


# TODO: We have to export framerate as a float for OTIO's current format. When OTIO supports
# fractional timecodes, we should export the framerate as a rational number instead.
# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190
def save_otio(
context: CliContext,
scenes: SceneList,
Expand All @@ -501,7 +506,7 @@ def save_otio(
video_name = context.video_stream.name
video_path = os.path.abspath(context.video_stream.path)
video_base_name = os.path.basename(context.video_stream.path)
frame_rate = context.video_stream.frame_rate
frame_rate = float(context.video_stream.frame_rate)

# List of track mapping to resource type.
# TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track.
Expand Down Expand Up @@ -534,12 +539,12 @@ def save_otio(
"duration": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": frame_rate,
"value": float((end - start).frame_num),
"value": round((end - start).seconds * frame_rate, 6),
},
"start_time": {
"OTIO_SCHEMA": "RationalTime.1",
"rate": frame_rate,
"value": float(start.frame_num),
"value": round(start.seconds * frame_rate, 6),
},
},
"enabled": True,
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
Loading
Loading