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
2 changes: 1 addition & 1 deletion docs/cli/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The `OpenCV <https://opencv.org/>`_ backend (usually `opencv-python <https://pyp

It is mostly reliable and fast, although can occasionally run into issues processing videos with multiple audio tracks or small amounts of frame corruption. You can use a custom version of the ``cv2`` package, or install either the `opencv-python` or `opencv-python-headless` packages from `pip`.

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.
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``/``--frame-rate``) 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.

Expand Down
10 changes: 8 additions & 2 deletions scenedetect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
:class:`SceneManager <scenedetect.scene_manager.SceneManager>`.
"""

import warnings
from logging import getLogger

# OpenCV is a required package, but we don't have it as an explicit dependency since we
Expand Down Expand Up @@ -115,8 +116,13 @@ def open_video(
:class:`VideoOpenFailure`: Constructing the VideoStream fails. If multiple backends have
been attempted, the error from the first backend will be returned.
"""
# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is
# used, once internal callers and downstream users have had a release to migrate.
if framerate is not None:
warnings.warn(
"`framerate` is deprecated and scheduled for removal in v0.9; "
"use `frame_rate` instead.",
DeprecationWarning,
stacklevel=2,
)
if frame_rate is None:
frame_rate = framerate
# A list of paths is opened as a single concatenated stream. VideoStreamConcat handles
Expand Down
10 changes: 8 additions & 2 deletions scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import logging
import os
import os.path
import warnings
from copy import copy

import click
Expand Down Expand Up @@ -361,8 +362,13 @@ def scenedetect(
ctx = ctx.obj
assert isinstance(ctx, CliContext)

# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `--framerate`
# is used, once downstream users have had a release to migrate to `--frame-rate`.
Comment thread
Breakthrough marked this conversation as resolved.
if framerate_legacy is not None:
warnings.warn(
"`--framerate` is deprecated; use `--frame-rate` instead.",
DeprecationWarning,
stacklevel=2,
)

if frame_rate is None:
frame_rate = framerate_legacy
elif framerate_legacy is not None:
Expand Down
10 changes: 8 additions & 2 deletions scenedetect/backends/moviepy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import os
import time
import typing as ty
import warnings
from fractions import Fraction
from logging import getLogger

Expand Down Expand Up @@ -94,8 +95,13 @@ def __init__(
"""
super().__init__()

# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is
# used, once internal callers and downstream users have had a release to migrate.
if framerate is not None:
warnings.warn(
"`framerate` is deprecated and scheduled for removal in v0.9; "
"use `frame_rate` instead.",
DeprecationWarning,
stacklevel=2,
)
if frame_rate is None:
frame_rate = framerate
# TODO: Investigate how MoviePy handles ffmpeg not being on PATH.
Expand Down
18 changes: 14 additions & 4 deletions scenedetect/backends/opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,13 @@ def __init__(
ValueError: specified frame rate is invalid
"""
super().__init__()
# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is
# used, once internal callers and downstream users have had a release to migrate.
if framerate is not None:
warnings.warn(
"`framerate` is deprecated and scheduled for removal in v0.9; "
"use `frame_rate` instead.",
DeprecationWarning,
stacklevel=2,
)
if frame_rate is None:
frame_rate = framerate
if path_or_device is not None:
Expand Down Expand Up @@ -395,8 +400,13 @@ def __init__(
"""
super().__init__()

# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is
# used, once internal callers and downstream users have had a release to migrate.
if framerate is not None:
warnings.warn(
"`framerate` is deprecated and scheduled for removal in v0.9; "
"use `frame_rate` instead.",
DeprecationWarning,
stacklevel=2,
)
if frame_rate is None:
frame_rate = framerate
if frame_rate is not None and frame_rate < MAX_FPS_DELTA:
Expand Down
10 changes: 8 additions & 2 deletions scenedetect/backends/pyav.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import os
import typing as ty
import warnings
from fractions import Fraction
from logging import getLogger

Expand Down Expand Up @@ -89,8 +90,13 @@ def __init__(
# refinement for frames FFmpeg flags as corrupt but still decodes.
super().__init__()

# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning when `framerate=` is
# used, once internal callers and downstream users have had a release to migrate.
if framerate is not None:
warnings.warn(
"`framerate` is deprecated and scheduled for removal in v0.9; "
"use `frame_rate` instead.",
DeprecationWarning,
stacklevel=2,
)
if frame_rate is None:
frame_rate = framerate
# Ensure specified frame rate is valid if set.
Expand Down
24 changes: 17 additions & 7 deletions scenedetect/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,12 @@ def framerate(self) -> float | None:
property returns an exact :class:`fractions.Fraction` and matches the naming used by
:attr:`scenedetect.video_stream.VideoStream.frame_rate`.
"""
# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning here once internal
# callers and downstream users have had a release to migrate to `frame_rate`.
warnings.warn(
"`framerate` is deprecated and scheduled for removal in v0.9; "
"use `frame_rate` instead.",
DeprecationWarning,
stacklevel=2,
)
if self._rate is None:
return None
return float(self._rate)
Expand Down Expand Up @@ -335,16 +339,18 @@ def get_frames(self) -> int:
def get_framerate(self) -> float | None:
"""[DEPRECATED] Get Framerate: Returns the framerate used by the FrameTimecode object.

Use the `framerate` property instead.
Use the `frame_rate` property instead.

:meta private:
"""
warnings.warn(
"get_framerate() is deprecated, use the `framerate` property instead.",
"get_framerate() is deprecated, use the `frame_rate` property instead.",
DeprecationWarning,
stacklevel=2,
)
return self.framerate
if self.frame_rate is None:
return None
return float(self.frame_rate)

def equal_frame_rate(self, other: "float | Fraction | FrameTimecode") -> bool:
"""Determine whether the passed frame rate equals this object's frame rate.
Expand All @@ -368,8 +374,12 @@ def equal_frame_rate(self, other: "float | Fraction | FrameTimecode") -> bool:

def equal_framerate(self, fps) -> bool:
"""[DEPRECATED] Use :meth:`equal_frame_rate` instead."""
# TODO(https://scenedetect.com/issue/548): emit DeprecationWarning here once internal
# callers and downstream users have had a release to migrate to `equal_frame_rate`.
warnings.warn(
"`equal_framerate()` is deprecated and scheduled for removal in v0.9; "
"use `equal_frame_rate()` instead.",
DeprecationWarning,
stacklevel=2,
)
return self.equal_frame_rate(fps)

@property
Expand Down
2 changes: 1 addition & 1 deletion scenedetect/video_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class FrameRateUnavailable(VideoOpenFailure):

def __init__(self):
super().__init__(
"Unable to obtain video framerate! Specify `framerate` manually, or"
"Unable to obtain video framerate! Specify `frame_rate` manually, or"
" re-encode/re-mux the video and try again."
)

Expand Down
10 changes: 7 additions & 3 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

These tests demonstrate common workflow patterns used when integrating the PySceneDetect API."""

import pytest


def test_api_detect(test_video_file: str):
"""Demonstrate usage of the `detect()` function to process a complete video."""
Expand Down Expand Up @@ -73,15 +75,17 @@ def test_api_scene_manager_start_end_time(test_video_file: str):


def test_api_open_video_framerate_legacy_alias(test_video_file: str):
"""`open_video(framerate=...)` is the soft-deprecated alias for `frame_rate=` (issue #548).
"""`open_video(framerate=...)` is the deprecated alias for `frame_rate=` (issue #548).
Both forms must produce equivalent streams; when both are provided, `frame_rate` wins."""
from scenedetect import open_video

legacy = open_video(test_video_file, framerate=30.0)
with pytest.warns(DeprecationWarning, match="frame_rate"):
legacy = open_video(test_video_file, framerate=30.0)
canonical = open_video(test_video_file, frame_rate=30.0)
assert legacy.frame_rate == canonical.frame_rate
# `frame_rate` takes precedence over `framerate` when both are provided.
both = open_video(test_video_file, frame_rate=30.0, framerate=24.0)
with pytest.warns(DeprecationWarning, match="frame_rate"):
both = open_video(test_video_file, frame_rate=30.0, framerate=24.0)
assert both.frame_rate == canonical.frame_rate


Expand Down
22 changes: 21 additions & 1 deletion tests/test_backend_opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

import cv2
import pytest

from scenedetect import ContentDetector, SceneManager
from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2
Expand All @@ -28,7 +29,7 @@

def test_open_image_sequence(test_image_sequence: str):
"""Test opening an image sequence. Currently, only VideoStreamCv2 supports this."""
sequence = VideoStreamCv2(test_image_sequence, framerate=25.0)
sequence = VideoStreamCv2(test_image_sequence, frame_rate=25.0)
assert sequence.is_seekable
assert sequence.frame_size[0] > 0 and sequence.frame_size[1] > 0
assert sequence.duration is not None
Expand All @@ -53,6 +54,25 @@ def test_capture_adapter(test_movie_clip: str):
assert [start.frame_num for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST


def test_capture_adapter_framerate_legacy_alias(test_movie_clip: str):
"""`framerate=` is the deprecated alias for `frame_rate=` on VideoCaptureAdapter."""
cap = cv2.VideoCapture(test_movie_clip)
assert cap.isOpened()
with pytest.warns(DeprecationWarning, match="frame_rate"):
legacy = VideoCaptureAdapter(cap, framerate=30.0)

cap = cv2.VideoCapture(test_movie_clip)
assert cap.isOpened()
canonical = VideoCaptureAdapter(cap, frame_rate=30.0)
assert canonical.frame_rate == legacy.frame_rate

cap = cv2.VideoCapture(test_movie_clip)
assert cap.isOpened()
with pytest.warns(DeprecationWarning, match="frame_rate"):
both = VideoCaptureAdapter(cap, frame_rate=30.0, framerate=24.0)
assert both.frame_rate == canonical.frame_rate


def test_decode_failures_exposed(corrupt_video_file: str):
"""The private decode failure counters must be surfaced by the public property on both
VideoStreamCv2 and VideoCaptureAdapter."""
Expand Down
40 changes: 21 additions & 19 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,34 +336,36 @@ def test_cli_detector_with_stats(tmp_path, detector_command: str):


def test_cli_framerate_legacy_alias():
"""`--framerate` is the soft-deprecated hidden alias for `-f/--frame-rate` (issue #548).
"""`--framerate` is the deprecated hidden alias for `-f/--frame-rate` (issue #548).
Both forms must be accepted; passing both should not error."""
# Canonical form.
exit_code, _ = invoke_cli(
["-i", DEFAULT_VIDEO_PATH, "--frame-rate", "30.0", "time", "-s", "2s", "-d", "4s"]
)
assert exit_code == 0
# Legacy form.
exit_code, _ = invoke_cli(
["-i", DEFAULT_VIDEO_PATH, "--framerate", "30.0", "time", "-s", "2s", "-d", "4s"]
)
with pytest.warns(DeprecationWarning, match="--frame-rate"):
exit_code, _ = invoke_cli(
["-i", DEFAULT_VIDEO_PATH, "--framerate", "30.0", "time", "-s", "2s", "-d", "4s"]
)
assert exit_code == 0
# Both forms together: `--frame-rate` wins, a warning is logged but no error.
exit_code, _ = invoke_cli(
[
"-i",
DEFAULT_VIDEO_PATH,
"--frame-rate",
"30.0",
"--framerate",
"24.0",
"time",
"-s",
"2s",
"-d",
"4s",
]
)
with pytest.warns(DeprecationWarning, match="--frame-rate"):
exit_code, _ = invoke_cli(
[
"-i",
DEFAULT_VIDEO_PATH,
"--frame-rate",
"30.0",
"--framerate",
"24.0",
"time",
"-s",
"2s",
"-d",
"4s",
]
)
assert exit_code == 0


Expand Down
34 changes: 27 additions & 7 deletions tests/test_timecode.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,16 @@ def test_frame_rate_property():
tc = FrameTimecode(timecode=0, fps=30.0)
assert tc.frame_rate == Fraction(30, 1)
assert isinstance(tc.frame_rate, Fraction)
assert tc.framerate == 30.0
assert isinstance(tc.framerate, float)
with pytest.warns(DeprecationWarning, match="frame_rate"):
legacy_frame_rate = tc.framerate

assert legacy_frame_rate == 30.0
assert isinstance(legacy_frame_rate, float)
# Constructed directly from a Fraction (the exact form for NTSC rates).
tc = FrameTimecode(timecode=0, fps=Fraction(30000, 1001))
assert tc.frame_rate == Fraction(30000, 1001)
assert tc.framerate == pytest.approx(float(Fraction(30000, 1001)))
with pytest.warns(DeprecationWarning, match="frame_rate"):
assert tc.framerate == pytest.approx(float(Fraction(30000, 1001)))
tc = FrameTimecode(timecode=0, fps=Fraction(24000, 1001))
assert tc.frame_rate == Fraction(24000, 1001)
# time_base equals 1 / frame_rate for CFR sources.
Expand Down Expand Up @@ -108,17 +112,21 @@ def test_frame_num_and_frame_rate_are_read_only():


def test_equal_frame_rate_legacy_alias():
"""`equal_framerate()` is the soft-deprecated alias for `equal_frame_rate()` (issue #548).
"""`equal_framerate()` is the deprecated alias for `equal_frame_rate()` (issue #548).
Both forms should produce identical results for every accepted operand type."""
tc = FrameTimecode(timecode=0, fps=30.0)
# float, Fraction, FrameTimecode operands.
other_tc = FrameTimecode(timecode=0, fps=30.0)
for other in (30.0, Fraction(30, 1), other_tc):
assert tc.equal_frame_rate(other) == tc.equal_framerate(other)
assert tc.equal_frame_rate(other) is True
expected = tc.equal_frame_rate(other)
with pytest.warns(DeprecationWarning, match="equal_frame_rate"):
actual = tc.equal_framerate(other)
assert actual == expected
assert actual is True
# Mismatched rate.
assert tc.equal_frame_rate(24.0) is False
assert tc.equal_framerate(24.0) is False
with pytest.warns(DeprecationWarning, match="equal_frame_rate"):
assert tc.equal_framerate(24.0) is False


def test_timecode_numeric():
Expand Down Expand Up @@ -555,3 +563,15 @@ def test_min_scene_len_accepts_timecode_like():
# ContentDetector: same.
ContentDetector(min_scene_len=FrameTimecode(timecode=15, fps=30.0))
ContentDetector(min_scene_len=Timecode(pts=500, time_base=Fraction(1, 1000)))


def test_get_framerate():
"""`get_framerate()` emits one warning and preserves its legacy float return value."""
tc = FrameTimecode(timecode=0, fps=30.0)

with pytest.warns(DeprecationWarning, match="frame_rate") as warning_info:
frame_rate = tc.get_framerate()

assert len(warning_info) == 1
assert frame_rate == 30.0
assert isinstance(frame_rate, float)
Loading
Loading